ZiB 4 miesięcy temu
commit
b24d6678af
100 zmienionych plików z 9164 dodań i 0 usunięć
  1. 6 0
      .dockerignore
  2. 5 0
      .githooks/pre-commit
  3. 7 0
      .gitignore
  4. 136 0
      README.md
  5. 33 0
      docker/Dockerfile
  6. 82 0
      docker/README.md
  7. 30 0
      docker/compose.yaml
  8. 46 0
      docker/entrypoint.sh
  9. 34 0
      docker/uvicorn_log.json
  10. 43 0
      pyproject.toml
  11. 0 0
      room_cal/__init__.py
  12. 10 0
      room_cal/_api_source.py
  13. 117 0
      room_cal/_health.py
  14. 189 0
      room_cal/_mnemonic.py
  15. 49 0
      room_cal/_periodic.py
  16. 405 0
      room_cal/_utils.py
  17. 0 0
      room_cal/actions/__init__.py
  18. 357 0
      room_cal/actions/_cli.py
  19. 89 0
      room_cal/actions/apply_discounts.py
  20. 114 0
      room_cal/actions/apply_rules.py
  21. 481 0
      room_cal/actions/diff.py
  22. 73 0
      room_cal/actions/fetch_closed.py
  23. 63 0
      room_cal/actions/fetch_feed.py
  24. 102 0
      room_cal/actions/fetch_prices.py
  25. 157 0
      room_cal/actions/fetch_reservations.py
  26. 52 0
      room_cal/actions/fetch_user_cal.py
  27. 36 0
      room_cal/actions/fill_prices.py
  28. 109 0
      room_cal/actions/local_refresh.py
  29. 39 0
      room_cal/actions/local_sync.py
  30. 54 0
      room_cal/actions/push_busy.py
  31. 56 0
      room_cal/actions/push_closed.py
  32. 53 0
      room_cal/actions/push_prices.py
  33. 71 0
      room_cal/api/__init__.py
  34. 128 0
      room_cal/api/_auth.py
  35. 12 0
      room_cal/api/_routes/__init__.py
  36. 42 0
      room_cal/api/_routes/actions.py
  37. 35 0
      room_cal/api/_routes/calc.py
  38. 70 0
      room_cal/api/_routes/chat.py
  39. 37 0
      room_cal/api/_routes/closed.py
  40. 42 0
      room_cal/api/_routes/credentials.py
  41. 44 0
      room_cal/api/_routes/discounts.py
  42. 35 0
      room_cal/api/_routes/health.py
  43. 20 0
      room_cal/api/_routes/prices.py
  44. 15 0
      room_cal/api/_routes/reload.py
  45. 37 0
      room_cal/api/_routes/reservations.py
  46. 23 0
      room_cal/api/_routes/services.py
  47. 53 0
      room_cal/api/_routes/sms.py
  48. 12 0
      room_cal/api/_routes/workflows.py
  49. 6 0
      room_cal/api/_server.py
  50. 36 0
      room_cal/busy/__init__.py
  51. 235 0
      room_cal/cal/__init__.py
  52. 17 0
      room_cal/cal/__main__.py
  53. 95 0
      room_cal/closed/__init__.py
  54. 105 0
      room_cal/config-all.json
  55. 54 0
      room_cal/config.json
  56. 99 0
      room_cal/discount/__init__.py
  57. 99 0
      room_cal/ical_feeds/__init__.py
  58. 8 0
      room_cal/ical_feeds/ical_feeds.json
  59. 262 0
      room_cal/mcp/__init__.py
  60. 5 0
      room_cal/mcp/_server.py
  61. 1 0
      room_cal/pricing/__init__.py
  62. 97 0
      room_cal/pricing/calc.py
  63. 169 0
      room_cal/pricing/price_calendar.py
  64. 110 0
      room_cal/reservations/__init__.py
  65. 98 0
      room_cal/rules/__init__.py
  66. 35 0
      room_cal/rules/_utils.py
  67. 11 0
      room_cal/rules/closed/__init__.py
  68. 20 0
      room_cal/rules/closed/future_block.py
  69. 11 0
      room_cal/rules/discount/__init__.py
  70. 42 0
      room_cal/rules/discount/_utils.py
  71. 46 0
      room_cal/rules/discount/fill_month.py
  72. 26 0
      room_cal/rules/discount/no_single_nights.py
  73. 51 0
      room_cal/rules/discount/short_notice.py
  74. 13 0
      room_cal/rules/price/__init__.py
  75. 10 0
      room_cal/rules/price/_types.py
  76. 48 0
      room_cal/rules/price/fire.py
  77. 13 0
      room_cal/service_api/__init__.py
  78. 492 0
      room_cal/service_api/_airbnb.py
  79. 689 0
      room_cal/service_api/_booking.py
  80. 18 0
      room_cal/service_api/_protocols.py
  81. 64 0
      room_cal/service_api/_scripts.py
  82. 18 0
      room_cal/service_api/_utils.py
  83. 150 0
      room_cal/service_api/client.py
  84. 74 0
      room_cal/service_api/credentials.py
  85. 36 0
      room_cal/service_api/types.py
  86. 61 0
      room_cal/service_api/typescript/airbnb/refresh-cookies.ts
  87. 285 0
      room_cal/service_api/typescript/airbnb/src/client.ts
  88. 183 0
      room_cal/service_api/typescript/airbnb/src/types.ts
  89. 183 0
      room_cal/service_api/typescript/booking/refresh-cookies.ts
  90. 396 0
      room_cal/service_api/typescript/booking/src/client.ts
  91. 46 0
      room_cal/service_api/typescript/booking/src/types.ts
  92. 617 0
      room_cal/service_api/typescript/package-lock.json
  93. 26 0
      room_cal/service_api/typescript/package.json
  94. 24 0
      room_cal/service_api/typescript/scripts/check-credentials.ts
  95. 35 0
      room_cal/service_api/typescript/scripts/dump-session.ts
  96. 9 0
      room_cal/service_api/typescript/scripts/install-browsers.ts
  97. 51 0
      room_cal/service_api/typescript/scripts/intercept-session.ts
  98. 92 0
      room_cal/service_api/typescript/scripts/record-fixtures.ts
  99. 23 0
      room_cal/service_api/typescript/scripts/refresh-all.ts
  100. 67 0
      room_cal/service_api/typescript/scripts/verify-api.ts

+ 6 - 0
.dockerignore

@@ -0,0 +1,6 @@
+**/__pycache__
+**/*.pyc
+**/.pytest_cache
+**/node_modules
+.venv
+tests

+ 5 - 0
.githooks/pre-commit

@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -e
+
+uv run python scripts/gen_example_config.py > room_cal/config-all.json
+git add room_cal/config-all.json

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+__pycache__/
+scripts/data/
+*.egg-info/
+build/
+dist/
+.pytest_cache/
+node_modules/

+ 136 - 0
README.md

@@ -0,0 +1,136 @@
+# room-cal
+
+A self-hosted calendar management tool for short-term rental hosts. It syncs pricing, availability, reservations, and discounts between a local CalDAV calendar and external booking platforms (Airbnb, Booking.com). A FastAPI HTTP server exposes the data to a Vue-based web calendar, and a FastMCP server exposes health checks to AI assistants.
+
+---
+
+## Installation
+
+See [docker/README.md](docker/README.md) for container-based deployment (recommended for production).
+
+For local development:
+
+```bash
+# Install dependencies (requires uv)
+uv sync --group dev
+
+# Activate git hooks
+git config core.hooksPath .githooks
+```
+
+---
+
+## Configuration
+
+Start from `room_cal/config.json` (the bundled defaults) and place an override at `~/.local/room_cal/config.json` (or `$ROOM_CAL_DATA_HOME/config.json`). Keys are deep-merged — you only need to include what you want to change.
+
+`room_cal/config-all.json` is a generated file that shows every available config key with its default value. It is kept up to date automatically by the pre-commit hook and is the canonical reference for what can be configured.
+
+---
+
+## Usage
+
+### CLI
+
+```bash
+# Sync reservations, prices, and availability for a date range
+uv run room-cal fetch-reservations 2026-07-01 2026-07-31
+uv run room-cal fetch-prices       2026-07-01 2026-07-31
+uv run room-cal push-prices        2026-07-01 2026-07-31
+uv run room-cal fetch-closed       2026-07-01 2026-07-31
+uv run room-cal push-closed        2026-07-01 2026-07-31
+
+# Apply discount rules and show a diff
+uv run room-cal apply-rules        2026-07-01 2026-07-31
+uv run room-cal apply-discounts    2026-07-01 2026-07-31
+uv run room-cal diff               2026-07-01 2026-07-31
+
+# Create default prices for dates with no local price set
+uv run room-cal fill-prices        2026-07-01 2026-07-31 [--weekday 150] [--weekend 200]
+
+# Fetch an external iCal feed
+uv run room-cal fetch-feed         2026-07-01 2026-07-31 --feed-name Fire
+
+# Sync from user calendar; push busy export calendar
+uv run room-cal fetch-user-cal     2026-07-01 2026-07-31
+uv run room-cal push-busy          2026-07-01 2026-07-31
+
+# Local-only workflows (no service API calls)
+uv run room-cal local-sync         2026-07-01 2026-07-31   # feeds + rules + fill + busy
+uv run room-cal local-refresh      2026-07-01 2026-07-31   # rewrite all local Room calendar events
+```
+
+### API server
+
+```bash
+uv run room-api
+```
+
+Serves the web calendar at `http://localhost:8000` and exposes REST endpoints for all sync operations.
+
+---
+
+## Refreshing credentials (`refresh-creds`)
+
+Session cookies for Airbnb and Booking.com are obtained via a Playwright browser session and must be periodically refreshed.
+
+```bash
+uv run refresh-creds [--output=file|stdout|api]
+```
+
+Three output modes are available:
+
+| Mode | Behaviour |
+|------|-----------|
+| `file` (default) | Writes credentials to `airbnb.env` and `booking.env` in `$ROOM_CAL_DATA_HOME` |
+| `stdout` | Prints credentials to stdout — useful for piping or inspection |
+| `api` | POSTs credentials directly to a running room-api instance |
+
+The `api` mode requires `api_url` to be set in your `config.json`:
+
+```json
+{
+  "api_url": "https://your-room-cal-instance.example.com"
+}
+```
+
+---
+
+## Web calendar (`calendar.html`)
+
+The web calendar provides a month view of all pricing and availability data. Key features:
+
+- **Month navigation** — prev/next buttons to move between months
+- **Day detail panel** — click any day to see its price, discount, reservation, and closed status for each service; prices and discounts are editable inline
+- **Sync mode** — toggle the sync bar on to see which days have mismatches between local CalDAV and remote platforms; a badge shows the total number of days with issues
+- **Per-day sync controls** — push (↑) or fetch (↓) prices, closed status, discounts, reservations, and user calendar data for individual days
+- **Per-month sync controls** — bulk push or fetch prices and closed status for the entire visible month; a Fill button creates default local prices for any missing dates
+- **iCal feed blocks** — external feed closures (e.g. a fire safety calendar) are shown and can be fetched per day
+- **Rule discounts** — pipeline-generated discounts are shown alongside manual discounts; mismatches between the rule output and what's on the calendar are surfaced
+- **Health indicator** — a header button polls `GET /health` and shows green/amber/red based on CalDAV connectivity and service credential status
+
+---
+
+## Developer notes
+
+### Git hooks
+
+The repo includes a pre-commit hook that regenerates `room_cal/config-all.json` automatically on every commit. After cloning, activate it with:
+
+```bash
+git config core.hooksPath .githooks
+```
+
+To regenerate the file manually:
+
+```bash
+uv run python scripts/gen_example_config.py > room_cal/config-all.json
+```
+
+### Tests
+
+All tests are integration-only and require a live CalDAV server:
+
+```bash
+uv run python -m pytest tests/
+```

+ 33 - 0
docker/Dockerfile

@@ -0,0 +1,33 @@
+FROM python:3.14-slim
+
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
+
+# Node.js 22 LTS
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends ca-certificates curl tini \
+    && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
+    && apt-get install -y --no-install-recommends nodejs \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Python deps — cached until pyproject.toml / uv.lock change
+COPY pyproject.toml uv.lock ./
+RUN uv sync --group api --group mcp --no-dev --frozen
+
+# TypeScript deps — cached until package*.json changes
+COPY room_cal/service_api/typescript/package*.json \
+     ./room_cal/service_api/typescript/
+RUN cd room_cal/service_api/typescript \
+    && npm ci \
+    && node_modules/.bin/playwright install-deps chromium
+
+# App source
+COPY room_cal/ ./room_cal/
+
+COPY docker/uvicorn_log.json /etc/uvicorn_log.json
+COPY docker/entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+ENTRYPOINT ["/usr/bin/tini", "--"]
+CMD ["/entrypoint.sh"]

+ 82 - 0
docker/README.md

@@ -0,0 +1,82 @@
+# Docker
+
+## podman-compose
+### API & MCP
+
+```bash
+podman-compose -f room_cal/docker/compose.yaml up -d
+```
+
+### Build
+
+```bash
+podman-compose -f room_cal/docker/compose.yaml build
+```
+
+## podman
+### Build
+
+```bash
+podman build -f Dockerfile --network host -t $IMG_NAME ..
+```
+
+### API
+
+```bash
+podman run --rm -it -p 8000:8000 -v ./config:/config $IMG_NAME
+```
+
+With healthcheck:
+
+```bash
+podman run --rm -it -p 8000:8000 -v ./config:/config \
+  --health-cmd "curl -sf http://localhost:8000/health" \
+  --health-interval 30s --health-timeout 10s \
+  --health-retries 3 --health-start-period 60s \
+  $IMG_NAME
+```
+
+### MCP
+
+```bash
+podman run --rm -it -p 8001:8001 -v ./config:/config $IMG_NAME /entrypoint.sh mcp
+```
+
+The `./config` volume must contain `config.json` (start with a copy of `room_cal/config.json`) and a valid `caldav.env` with the CalDAV credentials. Service_api credential files (`airbnb.env`, `booking.env`) live there too, once generated, so it needs a writeable mount
+
+Placing a `uvicorn_log.json` in `./config` overrides the default log config
+
+## Environment variables
+
+### Common
+
+| Variable | Default | Notes |
+|----------|---------|-------|
+| `ROOM_CAL_DATA_HOME` | `/config` | Config and credential root |
+
+### API service
+
+Default entrypoint. Variable defaults are baked in; override with environment variables if needed
+
+| Variable | Default | Notes |
+|----------|---------|-------|
+| `API_HOST` | `0.0.0.0` | Uvicorn bind address |
+| `API_PORT` | `8000` | Uvicorn bind port |
+| `LOG_LEVEL` | `info` | Uvicorn log level |
+| `AUTH_DISABLED` | *(unset)* | Set to `1` to bypass OIDC |
+| `OIDC_ISSUER` | *(unset)* | OIDC provider base URL |
+| `OIDC_CLIENT_ID` | *(unset)* | OIDC client ID |
+| `OIDC_CLIENT_SECRET` | *(unset)* | OIDC client secret |
+| `APP_BASE_URL` | `http://localhost:8000` | Public base URL; sets the OIDC redirect URI and cookie `Secure` flag |
+| `SESSION_SECRET` | random per restart | Set this to a static value to preserve sessions between runs |
+| `DISABLE_PERIODIC` | *(unset)* | Set to `1` to skip credential probes and all periodic syncs (reservations, prices) in the health loop |
+
+### MCP service
+
+Pass `mcp` as the first argument to `entrypoint.sh`. Variable defaults are baked in; override with environment variables if needed
+
+| Variable | Default | Notes |
+|----------|---------|-------|
+| `FASTMCP_HOST` | `0.0.0.0` | MCP server bind address |
+| `FASTMCP_PORT` | `8001` | MCP server bind port |
+| `FASTMCP_LOG_LEVEL` | `INFO` | MCP log level |

+ 30 - 0
docker/compose.yaml

@@ -0,0 +1,30 @@
+services:
+  api:
+    build:
+      context: ..
+      dockerfile: docker/Dockerfile
+    ports:
+      - "${API_PORT:-8000}:${API_PORT:-8000}"
+    volumes:
+      - ./config:/config
+    environment:
+      - TZ=${TZ:-}
+      - SMS_GATEWAY_SECRET=${SMS_GATEWAY_SECRET:-}
+    healthcheck:
+      test: ["CMD", "curl", "-sf", "http://localhost:${API_PORT:-8000}/health"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 60s
+
+  mcp:
+    build:
+      context: ..
+      dockerfile: docker/Dockerfile
+    command: ["/entrypoint.sh", "mcp"]
+    ports:
+      - "${MCP_PORT:-8001}:${MCP_PORT:-8001}"
+    volumes:
+      - ./config:/config
+    environment:
+      - TZ=${TZ:-}

+ 46 - 0
docker/entrypoint.sh

@@ -0,0 +1,46 @@
+#!/bin/sh
+set -e
+
+export PATH="/app/.venv/bin:$PATH"
+: "${ROOM_CAL_DATA_HOME:=/config}"
+export ROOM_CAL_DATA_HOME
+
+if [ -z "$TZ" ] && [ -f "$ROOM_CAL_DATA_HOME/config.json" ]; then
+  TZ=$(python3 -c "import json,sys; print(json.load(open('$ROOM_CAL_DATA_HOME/config.json')).get('timezone',''))" 2>/dev/null || true)
+fi
+: "${TZ:=UTC}"
+export TZ
+
+if [ -z "$ROOM_CAL_API_URL" ] && [ -f "$ROOM_CAL_DATA_HOME/config.json" ]; then
+  ROOM_CAL_API_URL=$(python3 -c "import json,sys; print(json.load(open('$ROOM_CAL_DATA_HOME/config.json')).get('api_url',''))" 2>/dev/null || true)
+fi
+export ROOM_CAL_API_URL
+
+SERVICE="${1:-api}"
+
+if [ "$SERVICE" = "mcp" ]; then
+  : "${FASTMCP_HOST:=0.0.0.0}"
+  : "${FASTMCP_PORT:=8001}"
+  : "${FASTMCP_LOG_LEVEL:=INFO}"
+  : "${FASTMCP_ENABLE_RICH_LOGGING:=false}"
+  : "${FASTMCP_ENABLE_RICH_TRACEBACKS:=false}"
+
+  export FASTMCP_TRANSPORT=http
+  export FASTMCP_HOST FASTMCP_PORT FASTMCP_LOG_LEVEL
+  export FASTMCP_ENABLE_RICH_LOGGING FASTMCP_ENABLE_RICH_TRACEBACKS
+
+  exec room-mcp
+else
+  : "${API_HOST:=0.0.0.0}"
+  : "${API_PORT:=8000}"
+  : "${LOG_LEVEL:=info}"
+
+  LOG_CONFIG=/etc/uvicorn_log.json
+  [ -f /config/uvicorn_log.json ] && LOG_CONFIG=/config/uvicorn_log.json
+
+  exec uvicorn "room_cal.api:create_app" --factory \
+    --host "$API_HOST" \
+    --port "$API_PORT" \
+    --log-config "$LOG_CONFIG" \
+    --log-level "$LOG_LEVEL"
+fi

+ 34 - 0
docker/uvicorn_log.json

@@ -0,0 +1,34 @@
+{
+  "version": 1,
+  "disable_existing_loggers": false,
+  "formatters": {
+    "default": {
+      "format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s",
+      "datefmt": "%Y-%m-%dT%H:%M:%S"
+    },
+    "access": {
+      "()": "uvicorn.logging.AccessFormatter",
+      "fmt": "%(asctime)s %(levelname)-8s %(client_addr)s \"%(request_line)s\" %(status_code)s",
+      "datefmt": "%Y-%m-%dT%H:%M:%S",
+      "use_colors": false
+    }
+  },
+  "handlers": {
+    "default": {
+      "class": "logging.StreamHandler",
+      "stream": "ext://sys.stdout",
+      "formatter": "default"
+    },
+    "access": {
+      "class": "logging.StreamHandler",
+      "stream": "ext://sys.stdout",
+      "formatter": "access"
+    }
+  },
+  "loggers": {
+    "uvicorn":        {"handlers": ["default"], "level": "INFO", "propagate": false},
+    "uvicorn.error":  {"handlers": ["default"], "level": "INFO", "propagate": false},
+    "uvicorn.access": {"handlers": ["access"],  "level": "INFO", "propagate": false},
+    "room_cal":       {"handlers": ["default"], "level": "INFO", "propagate": false}
+  }
+}

+ 43 - 0
pyproject.toml

@@ -0,0 +1,43 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "room-cal"
+version = "0.1.0"
+requires-python = ">=3.14"
+dependencies = [
+  "caldav",
+  "httpx>=0.27",
+  "icalendar",
+]
+
+[project.scripts]
+refresh-creds = "room_cal.service_api._scripts:refresh_cookies"
+room-cal = "room_cal.actions._cli:main"
+room-api = "room_cal.api._server:main"
+room-mcp = "room_cal.mcp._server:main"
+
+[dependency-groups]
+api = [
+  "fastapi>=0.115",
+  "uvicorn[standard]>=0.30",
+]
+mcp = [
+  "fastmcp>=2.0",
+]
+dev = [
+  {include-group = "api"},
+  {include-group = "mcp"},
+  "pytest",
+  "pytest-asyncio",
+  "pytest-xdist",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["room_cal"]
+
+[tool.pytest.ini_options]
+pythonpath = ["."]
+asyncio_mode = "auto"
+addopts = "-n auto --dist=loadfile"

+ 0 - 0
room_cal/__init__.py


+ 10 - 0
room_cal/_api_source.py

@@ -0,0 +1,10 @@
+from room_cal._utils import EventSource
+
+
+class ApiSource(EventSource):
+  def _ephemeral(self) -> bool:
+    return True
+
+
+_source = ApiSource('api')
+API_SOURCE = {'domain': _source._domain(), 'name': 'API', 'id': ''}

+ 117 - 0
room_cal/_health.py

@@ -0,0 +1,117 @@
+import asyncio
+import logging
+import os
+import random
+from dataclasses import dataclass
+from datetime import datetime, timezone
+
+from room_cal.cal import Calendar, DEFAULT_CALENDAR_NAME
+from room_cal.service_api import ServiceAPI
+from room_cal.service_api.types import ServiceAuthError
+from room_cal._utils import services, local_today
+
+_log = logging.getLogger(__name__)
+
+_POLL_MIN = 200
+_POLL_MAX = 400
+_CACHE_TTL = 2 * _POLL_MAX  # 800s — stale after two max-intervals with no update
+_cached: dict | None = None
+
+
+@dataclass
+class HealthResult:
+  ok: bool
+  reason: str | None = None
+
+  def to_dict(self) -> dict:
+    d: dict = {'ok': self.ok}
+    if self.reason is not None:
+      d['reason'] = self.reason
+    return d
+
+
+def get_cached() -> tuple[dict | None, bool]:
+  """Return (result, stale) where stale=True means the cache has expired."""
+  if _cached is None:
+    return None, False
+  age = (datetime.now(timezone.utc) - datetime.fromisoformat(_cached['checked_at'])).total_seconds()
+  return _cached, age > _CACHE_TTL
+
+
+async def caldav_checks(calendar_name: str = DEFAULT_CALENDAR_NAME) -> tuple[HealthResult, HealthResult]:
+  """Open one CalDAV connection and run both the connection and calendar-exists checks."""
+  cal = Calendar(calendar_name)
+  try:
+    async with await cal._client() as client:
+      conn = HealthResult(ok=True)
+      try:
+        await cal._get_calendar(client)
+        cal_result = HealthResult(ok=True)
+      except Exception as e:
+        cal_result = HealthResult(ok=False, reason=str(e))
+  except Exception as e:
+    conn = HealthResult(ok=False, reason=str(e))
+    cal_result = HealthResult(ok=False, reason='connection failed')
+  return conn, cal_result
+
+
+async def _probe_service(key: str, api: ServiceAPI) -> HealthResult:
+  today = local_today().isoformat()
+  try:
+    await api._clients[key].get_calendar(today, today)
+    return HealthResult(ok=True)
+  except ServiceAuthError as e:
+    return HealthResult(ok=False, reason=str(e))
+  except Exception as e:
+    return HealthResult(ok=False, reason=f'probe error: {e}')
+
+
+async def credentials_checks(api: ServiceAPI | None = None) -> dict[str, HealthResult]:
+  """Probe each configured service's live session."""
+  if api is None:
+    api = ServiceAPI()
+  keys = [s.key for s in services() if s.key in api._clients]
+  if not keys:
+    return {}
+  results = await asyncio.gather(*[_probe_service(k, api) for k in keys])
+  return dict(zip(keys, results))
+
+
+async def probe_once() -> None:
+  """Run one health probe, update the cache, and dispatch periodic work if all-green."""
+  global _cached
+  periodic_disabled = bool(os.getenv('DISABLE_PERIODIC'))
+  try:
+    api = ServiceAPI()
+    conn, cal_result = await caldav_checks(DEFAULT_CALENDAR_NAME)
+    creds = {} if periodic_disabled else await credentials_checks(api)
+    caldav_ok = conn.ok and cal_result.ok
+    creds_ok = all(v.ok for v in creds.values())
+    if caldav_ok and creds_ok:
+      overall = 'ok'
+    elif caldav_ok:
+      overall = 'degraded'
+    else:
+      overall = 'error'
+    _cached = {
+      'status': overall,
+      'checks': {
+        'caldav_connection': conn.to_dict(),
+        'calendar': cal_result.to_dict(),
+        'credentials': {k: v.to_dict() for k, v in creds.items()},
+      },
+      'checked_at': datetime.now(timezone.utc).isoformat(),
+    }
+    if caldav_ok and creds_ok:
+      from room_cal._periodic import run_due, _register_all_periodics
+      _register_all_periodics()
+      await run_due(api)
+  except Exception as e:
+    _log.error('health probe error: %s', e, exc_info=True)
+
+
+async def run_health_loop() -> None:
+  """Background task: probe health periodically, cache the result, and dispatch periodic work."""
+  while True:
+    await probe_once()
+    await asyncio.sleep(random.uniform(_POLL_MIN, _POLL_MAX))

+ 189 - 0
room_cal/_mnemonic.py

@@ -0,0 +1,189 @@
+import os
+
+_MN_BASE = 1626
+
+# fmt: off
+_WORDS = (
+  'academy', 'acrobat', 'active', 'actor', 'adam', 'admiral', 'adrian', 'africa', 'agenda', 'agent',
+  'airline', 'airport', 'aladdin', 'alarm', 'alaska', 'albert', 'albino', 'album', 'alcohol',
+  'alex', 'algebra', 'alibi', 'alice', 'alien', 'alpha', 'alpine', 'amadeus', 'amanda', 'amazon',
+  'amber', 'america', 'amigo', 'analog', 'anatomy', 'angel', 'animal', 'antenna', 'antonio',
+  'apollo', 'april', 'archive', 'arctic', 'arizona', 'arnold', 'aroma', 'arthur', 'artist', 'asia',
+  'aspect', 'aspirin', 'athena', 'athlete', 'atlas', 'audio', 'august', 'austria', 'axiom', 'aztec',
+  'balance', 'ballad', 'banana', 'bandit', 'banjo', 'barcode', 'baron', 'basic', 'battery',
+  'belgium', 'berlin', 'bermuda', 'bernard', 'bikini', 'binary', 'bingo', 'biology', 'block',
+  'blonde', 'bonus', 'boris', 'boston', 'boxer', 'brandy', 'bravo', 'brazil', 'bronze', 'brown',
+  'bruce', 'bruno', 'burger', 'burma', 'cabinet', 'cactus', 'cafe', 'cairo', 'cake', 'calypso',
+  'camel', 'camera', 'campus', 'canada', 'canal', 'cannon', 'canoe', 'cantina', 'canvas', 'canyon',
+  'capital', 'caramel', 'caravan', 'carbon', 'cargo', 'carlo', 'carol', 'carpet', 'cartel',
+  'casino', 'castle', 'castro', 'catalog', 'caviar', 'cecilia', 'cement', 'center', 'century',
+  'ceramic', 'chamber', 'chance', 'change', 'chaos', 'charlie', 'charm', 'charter', 'chef',
+  'chemist', 'cherry', 'chess', 'chicago', 'chicken', 'chief', 'china', 'cigar', 'cinema', 'circus',
+  'citizen', 'city', 'clara', 'classic', 'claudia', 'clean', 'client', 'climax', 'clinic', 'clock',
+  'club', 'cobra', 'coconut', 'cola', 'collect', 'colombo', 'colony', 'color', 'combat', 'comedy',
+  'comet', 'command', 'compact', 'company', 'complex', 'concept', 'concert', 'connect', 'consul',
+  'contact', 'context', 'contour', 'control', 'convert', 'copy', 'corner', 'corona', 'correct',
+  'cosmos', 'couple', 'courage', 'cowboy', 'craft', 'crash', 'credit', 'cricket', 'critic', 'crown',
+  'crystal', 'cuba', 'culture', 'dallas', 'dance', 'daniel', 'david', 'decade', 'decimal',
+  'deliver', 'delta', 'deluxe', 'demand', 'demo', 'denmark', 'derby', 'design', 'detect', 'develop',
+  'diagram', 'dialog', 'diamond', 'diana', 'diego', 'diesel', 'diet', 'digital', 'dilemma',
+  'diploma', 'direct', 'disco', 'disney', 'distant', 'doctor', 'dollar', 'dominic', 'domino',
+  'donald', 'dragon', 'drama', 'dublin', 'duet', 'dynamic', 'east', 'ecology', 'economy', 'edgar',
+  'egypt', 'elastic', 'elegant', 'element', 'elite', 'elvis', 'email', 'energy', 'engine',
+  'english', 'episode', 'equator', 'escort', 'ethnic', 'europe', 'everest', 'evident', 'exact',
+  'example', 'exit', 'exotic', 'export', 'express', 'extra', 'fabric', 'factor', 'falcon', 'family',
+  'fantasy', 'fashion', 'fiber', 'fiction', 'fidel', 'fiesta', 'figure', 'film', 'filter', 'final',
+  'finance', 'finish', 'finland', 'flash', 'florida', 'flower', 'fluid', 'flute', 'focus', 'ford',
+  'forest', 'formal', 'format', 'formula', 'fortune', 'forum', 'fragile', 'france', 'frank',
+  'friend', 'frozen', 'future', 'gabriel', 'galaxy', 'gallery', 'gamma', 'garage', 'garden',
+  'garlic', 'gemini', 'general', 'genetic', 'genius', 'germany', 'global', 'gloria', 'golf',
+  'gondola', 'gong', 'good', 'gordon', 'gorilla', 'grand', 'granite', 'graph', 'green', 'group',
+  'guide', 'guitar', 'guru', 'hand', 'happy', 'harbor', 'harmony', 'harvard', 'havana', 'hawaii',
+  'helena', 'hello', 'henry', 'hilton', 'history', 'horizon', 'hotel', 'human', 'humor', 'icon',
+  'idea', 'igloo', 'igor', 'image', 'impact', 'import', 'index', 'india', 'indigo', 'input',
+  'insect', 'instant', 'iris', 'italian', 'jacket', 'jacob', 'jaguar', 'janet', 'japan', 'jargon',
+  'jazz', 'jeep', 'john', 'joker', 'jordan', 'jumbo', 'june', 'jungle', 'junior', 'jupiter',
+  'karate', 'karma', 'kayak', 'kermit', 'kilo', 'king', 'koala', 'korea', 'labor', 'lady', 'lagoon',
+  'laptop', 'laser', 'latin', 'lava', 'lecture', 'left', 'legal', 'lemon', 'level', 'lexicon',
+  'liberal', 'libra', 'limbo', 'limit', 'linda', 'linear', 'lion', 'liquid', 'liter', 'little',
+  'llama', 'lobby', 'lobster', 'local', 'logic', 'logo', 'lola', 'london', 'lotus', 'lucas',
+  'lunar', 'machine', 'macro', 'madam', 'madonna', 'madrid', 'maestro', 'magic', 'magnet', 'magnum',
+  'major', 'mama', 'mambo', 'manager', 'mango', 'manila', 'marco', 'marina', 'market', 'mars',
+  'martin', 'marvin', 'master', 'matrix', 'maximum', 'media', 'medical', 'mega', 'melody', 'melon',
+  'memo', 'mental', 'mentor', 'menu', 'mercury', 'message', 'metal', 'meteor', 'meter', 'method',
+  'metro', 'mexico', 'miami', 'micro', 'million', 'mineral', 'minimum', 'minus', 'minute',
+  'miracle', 'mirage', 'miranda', 'mister', 'mixer', 'mobile', 'model', 'modem', 'modern',
+  'modular', 'moment', 'monaco', 'monica', 'monitor', 'mono', 'monster', 'montana', 'morgan',
+  'motel', 'motif', 'motor', 'mozart', 'multi', 'museum', 'music', 'mustang', 'natural', 'neon',
+  'nepal', 'neptune', 'nerve', 'neutral', 'nevada', 'news', 'ninja', 'nirvana', 'normal', 'nova',
+  'novel', 'nuclear', 'numeric', 'nylon', 'oasis', 'object', 'observe', 'ocean', 'octopus',
+  'olivia', 'olympic', 'omega', 'opera', 'optic', 'optimal', 'orange', 'orbit', 'organic', 'orient',
+  'origin', 'orlando', 'oscar', 'oxford', 'oxygen', 'ozone', 'pablo', 'pacific', 'pagoda', 'palace',
+  'pamela', 'panama', 'panda', 'panel', 'panic', 'paradox', 'pardon', 'paris', 'parker', 'parking',
+  'parody', 'partner', 'passage', 'passive', 'pasta', 'pastel', 'patent', 'patriot', 'patrol',
+  'patron', 'pegasus', 'pelican', 'penguin', 'pepper', 'percent', 'perfect', 'perfume', 'period',
+  'permit', 'person', 'peru', 'phone', 'photo', 'piano', 'picasso', 'picnic', 'picture', 'pigment',
+  'pilgrim', 'pilot', 'pirate', 'pixel', 'pizza', 'planet', 'plasma', 'plaster', 'plastic', 'plaza',
+  'pocket', 'poem', 'poetic', 'poker', 'polaris', 'police', 'politic', 'polo', 'polygon', 'pony',
+  'popcorn', 'popular', 'postage', 'postal', 'precise', 'prefix', 'premium', 'present', 'price',
+  'prince', 'printer', 'prism', 'private', 'product', 'profile', 'program', 'project', 'protect',
+  'proton', 'public', 'pulse', 'puma', 'pyramid', 'queen', 'radar', 'radio', 'random', 'rapid',
+  'rebel', 'record', 'recycle', 'reflex', 'reform', 'regard', 'regular', 'relax', 'report',
+  'reptile', 'reverse', 'ricardo', 'ringo', 'ritual', 'robert', 'robot', 'rocket', 'rodeo', 'romeo',
+  'royal', 'russian', 'safari', 'salad', 'salami', 'salmon', 'salon', 'salute', 'samba', 'sandra',
+  'santana', 'sardine', 'school', 'screen', 'script', 'second', 'secret', 'section', 'segment',
+  'select', 'seminar', 'senator', 'senior', 'sensor', 'serial', 'service', 'sheriff', 'shock',
+  'sierra', 'signal', 'silicon', 'silver', 'similar', 'simon', 'single', 'siren', 'slogan',
+  'social', 'soda', 'solar', 'solid', 'solo', 'sonic', 'soviet', 'special', 'speed', 'spiral',
+  'spirit', 'sport', 'static', 'station', 'status', 'stereo', 'stone', 'stop', 'street', 'strong',
+  'student', 'studio', 'style', 'subject', 'sultan', 'super', 'susan', 'sushi', 'suzuki', 'switch',
+  'symbol', 'system', 'tactic', 'tahiti', 'talent', 'tango', 'tarzan', 'taxi', 'telex', 'tempo',
+  'tennis', 'texas', 'textile', 'theory', 'thermos', 'tiger', 'titanic', 'tokyo', 'tomato', 'topic',
+  'tornado', 'toronto', 'torpedo', 'total', 'totem', 'tourist', 'tractor', 'traffic', 'transit',
+  'trapeze', 'travel', 'tribal', 'trick', 'trident', 'trilogy', 'tripod', 'tropic', 'trumpet',
+  'tulip', 'tuna', 'turbo', 'twist', 'ultra', 'uniform', 'union', 'uranium', 'vacuum', 'valid',
+  'vampire', 'vanilla', 'vatican', 'velvet', 'ventura', 'venus', 'vertigo', 'veteran', 'victor',
+  'video', 'vienna', 'viking', 'village', 'vincent', 'violet', 'violin', 'virtual', 'virus', 'visa',
+  'vision', 'visitor', 'visual', 'vitamin', 'viva', 'vocal', 'vodka', 'volcano', 'voltage',
+  'volume', 'voyage', 'water', 'weekend', 'welcome', 'western', 'window', 'winter', 'wizard',
+  'wolf', 'world', 'xray', 'yankee', 'yoga', 'yogurt', 'yoyo', 'zebra', 'zero', 'zigzag', 'zipper',
+  'zodiac', 'zoom', 'abraham', 'action', 'address', 'alabama', 'alfred', 'almond', 'ammonia',
+  'analyze', 'annual', 'answer', 'apple', 'arena', 'armada', 'arsenal', 'atlanta', 'atomic',
+  'avenue', 'average', 'bagel', 'baker', 'ballet', 'bambino', 'bamboo', 'barbara', 'basket',
+  'bazaar', 'benefit', 'bicycle', 'bishop', 'blitz', 'bonjour', 'bottle', 'bridge', 'british',
+  'brother', 'brush', 'budget', 'cabaret', 'cadet', 'candle', 'capitan', 'capsule', 'career',
+  'cartoon', 'channel', 'chapter', 'cheese', 'circle', 'cobalt', 'cockpit', 'college', 'compass',
+  'comrade', 'condor', 'crimson', 'cyclone', 'darwin', 'declare', 'degree', 'delete', 'delphi',
+  'denver', 'desert', 'divide', 'dolby', 'domain', 'domingo', 'double', 'drink', 'driver', 'eagle',
+  'earth', 'echo', 'eclipse', 'editor', 'educate', 'edward', 'effect', 'electra', 'emerald',
+  'emotion', 'empire', 'empty', 'escape', 'eternal', 'evening', 'exhibit', 'expand', 'explore',
+  'extreme', 'ferrari', 'first', 'flag', 'folio', 'forget', 'forward', 'freedom', 'fresh', 'friday',
+  'fuji', 'galileo', 'garcia', 'genesis', 'gold', 'gravity', 'habitat', 'hamlet', 'harlem',
+  'helium', 'holiday', 'house', 'hunter', 'ibiza', 'iceberg', 'imagine', 'infant', 'isotope',
+  'jackson', 'jamaica', 'jasmine', 'java', 'jessica', 'judo', 'kitchen', 'lazarus', 'letter',
+  'license', 'lithium', 'loyal', 'lucky', 'magenta', 'mailbox', 'manual', 'marble', 'mary',
+  'maxwell', 'mayor', 'milk', 'monarch', 'monday', 'money', 'morning', 'mother', 'mystery',
+  'native', 'nectar', 'nelson', 'network', 'next', 'nikita', 'nobel', 'nobody', 'nominal', 'norway',
+  'nothing', 'number', 'october', 'office', 'oliver', 'opinion', 'option', 'order', 'outside',
+  'package', 'pancake', 'pandora', 'panther', 'papa', 'patient', 'pattern', 'pedro', 'pencil',
+  'people', 'phantom', 'philips', 'pioneer', 'pluto', 'podium', 'portal', 'potato', 'prize',
+  'process', 'protein', 'proxy', 'pump', 'pupil', 'python', 'quality', 'quarter', 'quiet', 'rabbit',
+  'radical', 'radius', 'rainbow', 'ralph', 'ramirez', 'ravioli', 'raymond', 'respect', 'respond',
+  'result', 'resume', 'retro', 'richard', 'right', 'risk', 'river', 'roger', 'roman', 'rondo',
+  'sabrina', 'salary', 'salsa', 'sample', 'samuel', 'saturn', 'savage', 'scarlet', 'scoop',
+  'scorpio', 'scratch', 'scroll', 'sector', 'serpent', 'shadow', 'shampoo', 'sharon', 'sharp',
+  'short', 'shrink', 'silence', 'silk', 'simple', 'slang', 'smart', 'smoke', 'snake', 'society',
+  'sonar', 'sonata', 'soprano', 'source', 'sparta', 'sphere', 'spider', 'sponsor', 'spring', 'acid',
+  'adios', 'agatha', 'alamo', 'alert', 'almanac', 'aloha', 'andrea', 'anita', 'arcade', 'aurora',
+  'avalon', 'baby', 'baggage', 'balloon', 'bank', 'basil', 'begin', 'biscuit', 'blue', 'bombay',
+  'brain', 'brenda', 'brigade', 'cable', 'carmen', 'cello', 'celtic', 'chariot', 'chrome', 'citrus',
+  'civil', 'cloud', 'common', 'compare', 'cool', 'copper', 'coral', 'crater', 'cubic', 'cupid',
+  'cycle', 'depend', 'door', 'dream', 'dynasty', 'edison', 'edition', 'enigma', 'equal', 'eric',
+  'event', 'evita', 'exodus', 'extend', 'famous', 'farmer', 'food', 'fossil', 'frog', 'fruit',
+  'geneva', 'gentle', 'george', 'giant', 'gilbert', 'gossip', 'gram', 'greek', 'grille', 'hammer',
+  'harvest', 'hazard', 'heaven', 'herbert', 'heroic', 'hexagon', 'husband', 'immune', 'inca',
+  'inch', 'initial', 'isabel', 'ivory', 'jason', 'jerome', 'joel', 'joshua', 'journal', 'judge',
+  'juliet', 'jump', 'justice', 'kimono', 'kinetic', 'leonid', 'lima', 'maze', 'medusa', 'member',
+  'memphis', 'michael', 'miguel', 'milan', 'mile', 'miller', 'mimic', 'mimosa', 'mission', 'monkey',
+  'moral', 'moses', 'mouse', 'nancy', 'natasha', 'nebula', 'nickel', 'nina', 'noise', 'orchid',
+  'oregano', 'origami', 'orinoco', 'orion', 'othello', 'paper', 'paprika', 'prelude', 'prepare',
+  'pretend', 'profit', 'promise', 'provide', 'puzzle', 'remote', 'repair', 'reply', 'rival',
+  'riviera', 'robin', 'rose', 'rover', 'rudolf', 'saga', 'sahara', 'scholar', 'shelter', 'ship',
+  'shoe', 'sigma', 'sister', 'sleep', 'smile', 'spain', 'spark', 'split', 'spray', 'square',
+  'stadium', 'star', 'storm', 'story', 'strange', 'stretch', 'stuart', 'subway', 'sugar', 'sulfur',
+  'summer', 'survive', 'sweet', 'swim', 'table', 'taboo', 'target', 'teacher', 'telecom', 'temple',
+  'tibet', 'ticket', 'tina', 'today', 'toga', 'tommy', 'tower', 'trivial', 'tunnel', 'turtle',
+  'twin', 'uncle', 'unicorn', 'unique', 'update', 'valery', 'vega', 'version', 'voodoo', 'warning',
+  'william', 'wonder', 'year', 'yellow', 'young', 'absent', 'absorb', 'accent', 'alfonso', 'alias',
+  'ambient', 'andy', 'anvil', 'appear', 'apropos', 'archer', 'ariel', 'armor', 'arrow', 'austin',
+  'avatar', 'axis', 'baboon', 'bahama', 'bali', 'balsa', 'bazooka', 'beach', 'beast', 'beatles',
+  'beauty', 'before', 'benny', 'betty', 'between', 'beyond', 'billy', 'bison', 'blast', 'bless',
+  'bogart', 'bonanza', 'book', 'border', 'brave', 'bread', 'break', 'broken', 'bucket', 'buenos',
+  'buffalo', 'bundle', 'button', 'buzzer', 'byte', 'caesar', 'camilla', 'canary', 'candid',
+  'carrot', 'cave', 'chant', 'child', 'choice', 'chris', 'cipher', 'clarion', 'clark', 'clever',
+  'cliff', 'clone', 'conan', 'conduct', 'congo', 'content', 'costume', 'cotton', 'cover', 'crack',
+  'current', 'danube', 'data', 'decide', 'desire', 'detail', 'dexter', 'dinner', 'dispute', 'donor',
+  'druid', 'drum', 'easy', 'eddie', 'enjoy', 'enrico', 'epoxy', 'erosion', 'except', 'exile',
+  'explain', 'fame', 'fast', 'father', 'felix', 'field', 'fiona', 'fire', 'fish', 'flame', 'flex',
+  'flipper', 'float', 'flood', 'floor', 'forbid', 'forever', 'fractal', 'frame', 'freddie', 'front',
+  'fuel', 'gallop', 'game', 'garbo', 'gate', 'gibson', 'ginger', 'giraffe', 'gizmo', 'glass',
+  'goblin', 'gopher', 'grace', 'gray', 'gregory', 'grid', 'griffin', 'ground', 'guest', 'gustav',
+  'gyro', 'hair', 'halt', 'harris', 'heart', 'heavy', 'herman', 'hippie', 'hobby', 'honey', 'hope',
+  'horse', 'hostel', 'hydro', 'imitate', 'info', 'ingrid', 'inside', 'invent', 'invest', 'invite',
+  'iron', 'ivan', 'james', 'jester', 'jimmy', 'join', 'joseph', 'juice', 'julius', 'july', 'justin',
+  'kansas', 'karl', 'kevin', 'kiwi', 'ladder', 'lake', 'laura', 'learn', 'legacy', 'legend',
+  'lesson', 'life', 'light', 'list', 'locate', 'lopez', 'lorenzo', 'love', 'lunch', 'malta',
+  'mammal', 'margo', 'marion', 'mask', 'match', 'mayday', 'meaning', 'mercy', 'middle', 'mike',
+  'mirror', 'modest', 'morph', 'morris', 'nadia', 'nato', 'navy', 'needle', 'neuron', 'never',
+  'newton', 'nice', 'night', 'nissan', 'nitro', 'nixon', 'north', 'oberon', 'octavia', 'ohio',
+  'olga', 'open', 'opus', 'orca', 'oval', 'owner', 'page', 'paint', 'palma', 'parade', 'parent',
+  'parole', 'paul', 'peace', 'pearl', 'perform', 'phoenix', 'phrase', 'pierre', 'pinball', 'place',
+  'plate', 'plato', 'plume', 'pogo', 'point', 'polite', 'polka', 'poncho', 'powder', 'prague',
+  'press', 'presto', 'pretty', 'prime', 'promo', 'quasi', 'quest', 'quick', 'quiz', 'quota', 'race',
+  'rachel', 'raja', 'ranger', 'region', 'remark', 'rent', 'reward', 'rhino', 'ribbon', 'rider',
+  'road', 'rodent', 'round', 'rubber', 'ruby', 'rufus', 'sabine', 'saddle', 'sailor', 'saint',
+  'salt', 'satire', 'scale', 'scuba', 'season', 'secure', 'shake', 'shallow', 'shannon', 'shave',
+  'shelf', 'sherman', 'shine', 'shirt', 'side', 'sinatra', 'sincere', 'size', 'slalom', 'slow',
+  'small', 'snow', 'sofia', 'song', 'sound', 'south', 'speech', 'spell', 'spend', 'spoon', 'stage',
+  'stamp', 'stand', 'state', 'stella', 'stick', 'sting', 'stock', 'store', 'sunday', 'sunset',
+  'support', 'sweden', 'swing', 'tape', 'think', 'thomas', 'tictac', 'time', 'toast', 'tobacco',
+  'tonight', 'torch', 'torso', 'touch', 'toyota', 'trade', 'tribune', 'trinity', 'triton', 'truck',
+  'trust', 'type', 'under', 'unit', 'urban', 'urgent', 'user', 'value', 'vendor', 'venice',
+  'verona', 'vibrate', 'virgo', 'visible', 'vista', 'vital', 'voice', 'vortex', 'waiter', 'watch',
+  'wave', 'weather', 'wedding', 'wheel', 'whiskey', 'wisdom', 'deal', 'null', 'nurse', 'quebec',
+  'reserve', 'reunion', 'roof', 'singer', 'verbal', 'amen', 'ego', 'fax', 'jet', 'job', 'rio',
+  'ski', 'yes',
+)
+# fmt: on
+
+
+def door_code() -> str:
+  """Generate a random 4-digit door code as a zero-padded string."""
+  x = int.from_bytes(os.urandom(2), "little")
+  return f"{x % 10000:04d}"
+
+
+def wifi_key() -> str:
+  """Generate a human-readable wifi key from 4 random bytes."""
+  x = int.from_bytes(os.urandom(4), "little")
+  return f"{_WORDS[x % _MN_BASE]}-{_WORDS[(x // _MN_BASE) % _MN_BASE]}-{_WORDS[(x // (_MN_BASE * _MN_BASE)) % _MN_BASE]}"

+ 49 - 0
room_cal/_periodic.py

@@ -0,0 +1,49 @@
+import logging
+import random
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta, timezone
+from typing import Callable, Awaitable
+
+from room_cal.service_api import ServiceAPI
+
+_log = logging.getLogger(__name__)
+
+Callback = Callable[[ServiceAPI], Awaitable[None]]
+
+
+@dataclass
+class _Registration:
+  callback: Callback
+  interval: timedelta
+  next_run: datetime = field(init=False)
+
+  def __post_init__(self) -> None:
+    delay = random.uniform(0.5, 1.0) * self.interval.total_seconds()
+    self.next_run = datetime.now(timezone.utc) + timedelta(seconds=delay)
+
+
+_registry: list[_Registration] = []
+
+
+def register_periodic(callback: Callback, interval: timedelta) -> None:
+  if any(r.callback is callback for r in _registry):
+    return
+  _registry.append(_Registration(callback=callback, interval=interval))
+
+
+def _register_all_periodics() -> None:
+  """Import every module that registers periodic callbacks. Add new modules here."""
+  import room_cal.workflows.reservations  # noqa: F401
+  import room_cal.workflows.prices        # noqa: F401
+
+
+async def run_due(api: ServiceAPI) -> None:
+  now = datetime.now(timezone.utc)
+  for reg in _registry:
+    if now < reg.next_run:
+      continue
+    reg.next_run = now + reg.interval
+    try:
+      await reg.callback(api)
+    except Exception as e:
+      _log.warning('periodic callback %s failed: %s', reg.callback.__name__, e)

+ 405 - 0
room_cal/_utils.py

@@ -0,0 +1,405 @@
+import asyncio
+import json
+import os
+from dataclasses import dataclass, field
+from datetime import date, datetime, timedelta
+from importlib.resources import files
+from pathlib import Path
+from typing import Generic, TypeVar
+from zoneinfo import ZoneInfo
+
+_T = TypeVar('_T')
+
+
+@dataclass
+class FetchResult(Generic[_T]):
+  """Return type for fetch_* actions: the synced data plus a changed flag."""
+  data: _T
+  changed: bool
+
+  def __bool__(self) -> bool:
+    return self.changed
+
+
+_source_registry: dict[str, 'EventSource'] = {}
+
+
+class EventSource:
+  """Base class for event-producing objects. Registers the instance by domain name on init."""
+  def __init__(self, name: str) -> None:
+    self._name = name
+    _source_registry[name] = self
+
+  def _domain(self) -> str:
+    return self._name
+
+  def _ephemeral(self) -> bool:
+    return False
+
+
+def is_ephemeral_domain(name: str | None) -> bool:
+  """Return True if the named domain was registered as ephemeral (e.g. 'api')."""
+  if not name:
+    return False
+  source = _source_registry.get(name)
+  return source._ephemeral() if source else False
+
+
+def registered_domains() -> frozenset[str]:
+  """Return the set of all registered source domain names."""
+  return frozenset(_source_registry)
+
+
+def safe_json_loads(s: str | None) -> dict | None:
+  if not s:
+    return None
+  try:
+    return json.loads(s)
+  except (ValueError, TypeError):
+    return None
+
+
+def iter_dates(start: date, end: date):
+  d = start
+  while d <= end:
+    yield d
+    d += timedelta(days=1)
+
+
+def event_dates(event: dict, start_date: date, end_date: date):
+  """Yield dates covered by a CalDAV event dict, clamped to [start_date, end_date]."""
+  ev_start = event['start'].date()
+  ev_end = event['end'].date()
+  last_day = (ev_end - timedelta(days=1)) if event['all_day'] else ev_end
+  for d in iter_dates(ev_start, last_day):
+    if start_date <= d <= end_date:
+      yield d
+
+
+async def drop_stale_events(
+  events: list[dict],
+  covered: set[date],
+  *,
+  domain: str,
+  id_: str,
+  cal,
+) -> None:
+  """Delete events whose source domain/id match but whose date is no longer covered."""
+  to_delete = []
+  for ev in events:
+    xd = safe_json_loads(ev['x_data'])
+    stored = xd.get('source', {}) if xd else {}
+    if not isinstance(stored, dict):
+      continue
+    ev_date = ev['start'].date()
+    if ev_date not in covered and stored.get('domain') == domain and stored.get('id') == id_:
+      to_delete.append(ev)
+  if to_delete:
+    await asyncio.gather(*[cal.delete(ev) for ev in to_delete])
+
+
+def filter_events(
+  events: list[dict],
+  prefix: str,
+  *,
+  all_day: bool | None = None,
+  require_x_data: bool = True,
+) -> list[dict]:
+  return [
+    e for e in events
+    if e['title'].startswith(prefix)
+    and (all_day is None or e['all_day'] == all_day)
+    and (not require_x_data or e['x_data'])
+  ]
+
+
+def data_home() -> Path:
+  override = os.environ.get('ROOM_CAL_DATA_HOME')
+  return Path(override) if override else Path.home() / '.local' / 'room_cal'
+
+
+def _deep_merge(base: dict, override: dict) -> dict:
+  result = dict(base)
+  for k, v in override.items():
+    if k in result and isinstance(result[k], dict) and isinstance(v, dict):
+      result[k] = _deep_merge(result[k], v)
+    else:
+      result[k] = v
+  return result
+
+
+# ── Config dataclasses ─────────────────────────────────────────────────────────
+
+@dataclass
+class ServiceFees:
+  commission: float = 0.0
+  client_fee: float = 0.0
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'ServiceFees':
+    return cls(commission=d.get('commission', 0.0), client_fee=d.get('client_fee', 0.0))
+
+
+@dataclass
+class ServiceTaxes:
+  wrong: float | None = None
+  flat_tax: int = 0
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'ServiceTaxes':
+    return cls(wrong=d.get('wrong'), flat_tax=d.get('flat_tax', 0))
+
+
+@dataclass
+class ServiceConfig:
+  key: str
+  name: str
+  listing_id: str = ''
+  hotel_id: str = ''
+  hotel_account_id: str = ''
+  host_account_id: str = ''
+  room_id: str = ''
+  rate_id: str = ''
+  fees: ServiceFees = field(default_factory=ServiceFees)
+  taxes: ServiceTaxes = field(default_factory=ServiceTaxes)
+
+  @classmethod
+  def from_dict(cls, key: str, d: dict) -> 'ServiceConfig':
+    return cls(
+      key=key,
+      name=d['name'],
+      listing_id=d.get('listing_id', ''),
+      hotel_id=str(d.get('hotel_id', '')),
+      hotel_account_id=str(d.get('hotel_account_id', '')),
+      host_account_id=str(d.get('host_account_id', '')),
+      room_id=str(d.get('room_id', '')),
+      rate_id=str(d.get('rate_id', '')),
+      fees=ServiceFees.from_dict(d.get('fees', {})),
+      taxes=ServiceTaxes.from_dict(d.get('taxes', {})),
+    )
+
+
+@dataclass
+class GlobalTaxes:
+  sales: float = 0.0
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'GlobalTaxes':
+    return cls(sales=d.get('sales', 0.0))
+
+
+@dataclass
+class DefaultPrices:
+  weekday: int = 62
+  weekend: int = 72
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'DefaultPrices':
+    return cls(weekday=d.get('weekday', 62), weekend=d.get('weekend', 72))
+
+
+@dataclass
+class PropertyConfig:
+  timezone: str = 'America/Los_Angeles'
+  checkin_hour: int = 14
+  checkout_hour: int = 12
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'PropertyConfig':
+    return cls(
+      timezone=d.get('timezone', 'America/Los_Angeles'),
+      checkin_hour=d.get('checkin_hour', 14),
+      checkout_hour=d.get('checkout_hour', 12),
+    )
+
+
+@dataclass
+class GlobalConfig:
+  taxes: GlobalTaxes = field(default_factory=GlobalTaxes)
+  property: PropertyConfig = field(default_factory=PropertyConfig)
+  prices: DefaultPrices = field(default_factory=DefaultPrices)
+  reservation_window_days: int = 90
+  busy_calendar_name: str = 'Busy'
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'GlobalConfig':
+    return cls(
+      taxes=GlobalTaxes.from_dict(d.get('taxes', {})),
+      property=PropertyConfig.from_dict(d.get('property', {})),
+      prices=DefaultPrices.from_dict(d.get('prices', {})),
+      reservation_window_days=d.get('reservation_window_days', 90),
+      busy_calendar_name=d.get('busy_calendar_name', 'Busy'),
+    )
+
+
+@dataclass
+class PipelineConfig:
+  type: str
+  rules: list[str]
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'PipelineConfig':
+    return cls(type=d['type'], rules=d.get('rules', []))
+
+
+@dataclass
+class FutureBlockRule:
+  days_out: int = 150
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'FutureBlockRule':
+    return cls(days_out=d.get('days_out', 150))
+
+
+@dataclass
+class ShortNoticeRule:
+  max_days: int = 14
+  ramp_days: int = 7
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'ShortNoticeRule':
+    return cls(max_days=d.get('max_days', 14), ramp_days=d.get('ramp_days', 7))
+
+
+@dataclass
+class FillMonthRule:
+  discount_pct: int = 10
+  max_days: int = 30
+  ramp_days: int = 7
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'FillMonthRule':
+    return cls(
+      discount_pct=d.get('discount_pct', 10),
+      max_days=d.get('max_days', 30),
+      ramp_days=d.get('ramp_days', 7),
+    )
+
+
+@dataclass
+class FireRule:
+  day_of: int = 314
+  day_before: int = 111
+  day_after: int = 222
+  feed_name: str = 'Fire'
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'FireRule':
+    return cls(
+      day_of=d.get('day_of', 314),
+      day_before=d.get('day_before', 111),
+      day_after=d.get('day_after', 222),
+      feed_name=d.get('feed_name', 'Fire'),
+    )
+
+
+@dataclass
+class RulesConfig:
+  future_block: FutureBlockRule = field(default_factory=FutureBlockRule)
+  short_notice: ShortNoticeRule = field(default_factory=ShortNoticeRule)
+  fill_month: FillMonthRule = field(default_factory=FillMonthRule)
+  fire: FireRule = field(default_factory=FireRule)
+  pipelines: dict[str, PipelineConfig] = field(default_factory=dict)
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'RulesConfig':
+    return cls(
+      future_block=FutureBlockRule.from_dict(d.get('future_block', {})),
+      short_notice=ShortNoticeRule.from_dict(d.get('short_notice', {})),
+      fill_month=FillMonthRule.from_dict(d.get('fill_month', {})),
+      fire=FireRule.from_dict(d.get('fire', {})),
+      pipelines={k: PipelineConfig.from_dict(v) for k, v in d.get('pipelines', {}).items()},
+    )
+
+
+@dataclass
+class CalendarEntry:
+  name: str
+  url: str | None = None
+  caldav: str | None = None
+  past_days: int | None = None
+  future_days: int | None = None
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'CalendarEntry':
+    return cls(
+      name=d['name'],
+      url=d.get('url'),
+      caldav=d.get('caldav'),
+      past_days=d.get('past_days'),
+      future_days=d.get('future_days'),
+    )
+
+
+@dataclass
+class Config:
+  global_: GlobalConfig = field(default_factory=GlobalConfig)
+  services: dict[str, ServiceConfig] = field(default_factory=dict)
+  rules: RulesConfig = field(default_factory=RulesConfig)
+  calendars: list[CalendarEntry] = field(default_factory=list)
+  api_url: str | None = None
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'Config':
+    return cls(
+      global_=GlobalConfig.from_dict(d.get('global', {})),
+      services={k: ServiceConfig.from_dict(k, v) for k, v in d.get('services', {}).items()},
+      rules=RulesConfig.from_dict(d.get('rules', {})),
+      calendars=[CalendarEntry.from_dict(e) for e in d.get('calendars', [])],
+      api_url=d.get('api_url'),
+    )
+
+
+# ── Config loading ─────────────────────────────────────────────────────────────
+
+def get_config() -> Config:
+  """Load room_cal config.
+
+  Always starts from the bundled default config.json, then deep-merges
+  data_home()/config.json on top. Dict keys are merged recursively; lists
+  and scalar values in the user config replace their defaults entirely.
+  """
+  default = json.loads(files('room_cal').joinpath('config.json').read_text(encoding='utf-8'))
+  override_path = data_home() / 'config.json'
+  if not override_path.exists():
+    return Config.from_dict(default)
+  override = json.loads(override_path.read_text(encoding='utf-8'))
+  return Config.from_dict(_deep_merge(default, override))
+
+
+def local_today() -> date:
+  """Return today's date in the configured property timezone."""
+  tz = ZoneInfo(get_config().global_.property.timezone)
+  return datetime.now(tz).date()
+
+
+def services() -> list[ServiceConfig]:
+  """Return list of ServiceConfig for all configured services."""
+  return list(get_config().services.values())
+
+
+def service_name(key: str) -> str:
+  """Return the display name for a service key, falling back to key.capitalize()."""
+  svc = get_config().services.get(key)
+  return svc.name if svc else key.capitalize()
+
+
+def source_line(source: dict) -> str:
+  """Format a source dict {name, id} into a human-readable string."""
+  name = source.get('name', '')
+  id_ = source.get('id', '')
+  return f"{name} {id_}".strip()
+
+
+def read_env(path: Path) -> dict[str, str]:
+  env: dict[str, str] = {}
+  try:
+    for line in path.read_text().splitlines():
+      line = line.strip()
+      if not line or line.startswith('#') or '=' not in line:
+        continue
+      k, _, v = line.partition('=')
+      env[k.strip()] = v.strip().strip("'\"")
+  except FileNotFoundError:
+    pass
+  return env

+ 0 - 0
room_cal/actions/__init__.py


+ 357 - 0
room_cal/actions/_cli.py

@@ -0,0 +1,357 @@
+"""CLI dispatcher for room_cal sync/refresh/diff actions.
+
+Usage:
+  room-cal fetch-reservations 2026-07-01 2026-07-31
+  room-cal fetch-closed 2026-07-01
+  room-cal push-closed 2026-07-01 2026-07-31
+  room-cal fetch-user-cal 2026-07-01 2026-07-31
+  room-cal apply-rules 2026-07-01 2026-07-31 [--pipeline-name this_month]
+  room-cal fetch-feed 2026-07-01 2026-07-31 [--feed-name Fire] [--title "Block"]
+  room-cal fill-prices  2026-07-01 2026-07-31 [--weekday 150] [--weekend 200]
+  room-cal fetch-prices 2026-07-01 2026-07-31 [--service airbnb]
+  room-cal push-prices 2026-07-01 2026-07-31
+  room-cal apply-discounts 2026-07-01 2026-07-31
+  room-cal push-busy 2026-07-01 2026-07-31
+  room-cal local-sync 2026-07-01 2026-07-31
+  room-cal local-refresh 2026-07-01 2026-07-31
+  room-cal diff 2026-07-01 2026-07-31
+"""
+import argparse
+import asyncio
+from datetime import date
+
+
+def _date(s: str) -> date:
+  return date.fromisoformat(s)
+
+
+async def _fetch_reservations(start: date, end: date, args) -> None:
+  from room_cal.actions.fetch_reservations import fetch_reservations
+  result = await fetch_reservations(start, end)
+  for r in result.data:
+    print(f"  {r['service']} {r['checkin_date']} – {r['checkout_date']}: {r['guest_name']}")
+  print(f"  {len(result.data)} reservation(s) synced" + (" (changed)" if result.changed else ""))
+
+
+async def _fetch_closed(start: date, end: date, args) -> None:
+  from room_cal.actions.fetch_closed import fetch_closed
+  result = await fetch_closed(start, end)
+  for r in result.data:
+    print(f"  {r['date']}: {', '.join(r['closed_services'])}")
+  print(f"  {len(result.data)} closed event(s) synced" + (" (changed)" if result.changed else ""))
+
+
+async def _fetch_user_cal(start: date, end: date, args) -> None:
+  from room_cal.actions.fetch_user_cal import fetch_user_cal
+  result = await fetch_user_cal(start, end)
+  for d in result.data['closed']:
+    print(f"  closed: {d}")
+  print(f"  {len(result.data['closed'])} date(s) closed synced from user_cal" + (" (changed)" if result.changed else ""))
+
+
+async def _push_closed(start: date, end: date, args) -> None:
+  from room_cal.actions.push_closed import push_closed
+  results = await push_closed(start, end)
+  for r in results:
+    print(f"  {r['date']}: {r['action']}")
+  print(f"  {len(results)} date(s) changed")
+
+
+async def _apply_rules(start: date, end: date, args) -> None:
+  from room_cal.actions.apply_rules import apply_rules
+  results = await apply_rules(start, end, pipeline_name=args.pipeline_name or None)
+  total = 0
+  for name, dates in results.items():
+    for d in dates:
+      print(f"  {name}: {d}")
+    total += len(dates)
+  print(f"  {total} event(s) synced from rules")
+
+
+async def _fetch_feed(start: date, end: date, args) -> None:
+  from room_cal.actions.fetch_feed import fetch_feed
+  result = await fetch_feed(start, end, feed_name=args.feed_name, title=args.title or None)
+  for d in result.data:
+    print(f"  {d}")
+  print(f"  {len(result.data)} date(s) marked closed" + (" (changed)" if result.changed else ""))
+
+
+async def _fetch_prices(start: date, end: date, args) -> None:
+  from room_cal.actions.fetch_prices import fetch_prices
+  result = await fetch_prices(start, end, service=args.service or None)
+  for r in result.data:
+    print(f"  {r['date']}: net ${r['net']}")
+  print(f"  {len(result.data)} price event(s) written" + (" (changed)" if result.changed else ""))
+
+
+async def _push_prices(start: date, end: date, args) -> None:
+  from room_cal.actions.push_prices import push_prices
+  results = await push_prices(start, end)
+  for r in results:
+    prices_str = '  '.join(f"{s}: ${v}" for s, v in r['prices'].items())
+    print(f"  {r['date']}: {prices_str}")
+  print(f"  {len(results)} date(s) pushed")
+
+
+async def _fill_prices(start: date, end: date, args) -> None:
+  from room_cal.actions.fill_prices import fill_prices
+  results = await fill_prices(
+    start, end,
+    weekday=int(args.weekday) if args.weekday else None,
+    weekend=int(args.weekend) if args.weekend else None,
+  )
+  for r in results:
+    print(f"  {r['date']}: net ${r['net']}")
+  print(f"  {len(results)} price event(s) created")
+
+
+async def _apply_discounts(start: date, end: date, args) -> None:
+  from room_cal.actions.apply_discounts import apply_discounts
+  results = await apply_discounts(start, end)
+  for r in results:
+    old = '  '.join(f"{s}: ${v}" for s, v in r['old_prices'].items() if v is not None)
+    new = '  '.join(f"{s}: ${v}" for s, v in r['new_prices'].items())
+    print(f"  {r['date']}: {old}  →  {new}")
+  print(f"  {len(results)} date(s) updated")
+
+
+async def _push_busy(start: date, end: date, args) -> None:
+  from room_cal.actions.push_busy import push_busy
+  result = await push_busy(start, end)
+  for d in result.data['busy']:
+    print(f"  busy: {d}")
+  print(f"  {len(result.data['busy'])} date(s) busy" + (" (changed)" if result.changed else ""))
+
+
+async def _local_sync(start: date, end: date, args) -> None:
+  from room_cal.actions.local_sync import local_sync
+  await local_sync(start, end)
+
+
+async def _local_refresh(start: date, end: date, args) -> None:
+  from room_cal.actions.local_refresh import local_refresh
+  await local_refresh(start, end)
+
+
+def _fmt_table(rows: list[list[str]], header: list[str]) -> None:
+  all_rows = [header] + rows
+  widths = [max(len(r[i]) for r in all_rows) for i in range(len(header))]
+  sep = '  '
+  fmt = sep.join(f'{{:<{w}}}' for w in widths)
+  print('  ' + fmt.format(*header))
+  print('  ' + sep.join('-' * w for w in widths))
+  for row in rows:
+    print('  ' + fmt.format(*row))
+
+
+def _fmt_price(local: int | None, remote: int | None) -> str:
+  if local is None and remote is None:
+    return '—'
+  if local is None:
+    return f'—→${remote}'
+  if remote is None:
+    return f'${local}→—'
+  if local != remote:
+    return f'${local}→${remote}'
+  return f'${remote}'
+
+
+def _fmt_discount(d: dict | None) -> str:
+  if d is None:
+    return '—'
+  if d['type'] == 'flat':
+    return f"flat:${d['value']}"
+  return f"pct:{d['value']}%"
+
+
+async def _diff(start: date, end: date, args) -> None:
+  from room_cal.actions.diff import diff
+  from room_cal._utils import services
+  result = await diff(start, end)
+  svc_keys = [s.key for s in services()]
+  any_diff = False
+
+  prices = result['prices']
+  if prices:
+    any_diff = True
+    n = len(prices)
+    print(f"\n  Prices  ({n} date{'s' if n != 1 else ''} differ)")
+    header = ['date', 'local'] + svc_keys
+    rows = []
+    for p in prices:
+      net_str = f"${p['net']}" if p['net'] is not None else '—'
+      svc_cols = [
+        _fmt_price(
+          p['per_service'][svc]['local'] if svc in p['per_service'] else None,
+          p['per_service'][svc]['remote'] if svc in p['per_service'] else None,
+        )
+        for svc in svc_keys
+      ]
+      rows.append([str(p['date']), net_str] + svc_cols)
+    _fmt_table(rows, header)
+
+  closed = result['closed']
+  if closed:
+    any_diff = True
+    n = len(closed)
+    print(f"\n  Closed vs service_api  ({n} date{'s' if n != 1 else ''} differ)")
+    header = ['date', 'local'] + svc_keys
+    rows = []
+    for c in closed:
+      local_str = ', '.join(c['local']) if c['local'] else '—'
+      remote = c['remote'] or []
+      svc_cols = ['closed' if svc in remote else 'open' for svc in svc_keys]
+      rows.append([str(c['date']), local_str] + svc_cols)
+    _fmt_table(rows, header)
+
+  reservations = result['reservations']
+  if reservations:
+    any_diff = True
+    n = len(reservations)
+    print(f"\n  Reservations  ({n} {'change' if n == 1 else 'changes'})")
+    for r in reservations:
+      if r['status'] == 'added':
+        print(f"  [+] {r['service']}  {r['checkin_date']} → {r['checkout_date']}  {r['guest_name']}  ({r['reservation_id']})")
+      elif r['status'] == 'removed':
+        print(f"  [-] {r['service']}  {r['checkin_date']} → {r['checkout_date']}  {r['guest_name']}  ({r['reservation_id']})")
+      elif r['status'] == 'changed':
+        for c in r['changes']:
+          print(f"  [~] {r['reservation_id']}  {c['field']}: {c['local']!r} → {c['remote']!r}")
+
+  uc_closed = result['user_cal_closed']
+  if uc_closed:
+    any_diff = True
+    n = len(uc_closed)
+    print(f"\n  Closed vs user_cal  ({n} date{'s' if n != 1 else ''} differ)")
+    _fmt_table(
+      [[str(r['date']), 'closed' if r['in_room'] else 'open', 'closed' if r['in_user_cal'] else 'open']
+       for r in uc_closed],
+      ['date', 'room', 'user_cal'],
+    )
+
+  ical_feeds_closed = result['ical_feeds_closed']
+  for feed_name, rows_data in ical_feeds_closed.items():
+    any_diff = True
+    n = len(rows_data)
+    print(f"\n  Closed vs ical_feeds \"{feed_name}\"  ({n} date{'s' if n != 1 else ''} differ)")
+    _fmt_table(
+      [[str(r['date']), 'closed' if r['in_room'] else 'open', 'blocked' if r['in_ical_feeds'] else 'open']
+       for r in rows_data],
+      ['date', 'room', 'ical_feeds'],
+    )
+
+  rule_discounts = result['rule_discounts']
+  for pipeline_name, rows_data in rule_discounts.items():
+    any_diff = True
+    n = len(rows_data)
+    print(f"\n  Discounts vs rule \"{pipeline_name}\"  ({n} date{'s' if n != 1 else ''} differ)")
+    _fmt_table(
+      [[str(d['date']), _fmt_discount(d['local']), _fmt_discount(d['rule'])] for d in rows_data],
+      ['date', 'local', 'rule'],
+    )
+
+  rule_closed = result['rule_closed']
+  for pipeline_name, rows_data in rule_closed.items():
+    any_diff = True
+    n = len(rows_data)
+    print(f"\n  Closed vs rule \"{pipeline_name}\"  ({n} date{'s' if n != 1 else ''} differ)")
+    _fmt_table(
+      [[str(r['date']), 'closed' if r['in_room'] else 'open', 'closed' if r['in_rule'] else 'open']
+       for r in rows_data],
+      ['date', 'room', 'rule'],
+    )
+
+  rule_prices = result['rule_prices']
+  for pipeline_name, rows_data in rule_prices.items():
+    any_diff = True
+    n = len(rows_data)
+    print(f"\n  Prices vs rule \"{pipeline_name}\"  ({n} date{'s' if n != 1 else ''} differ)")
+    _fmt_table(
+      [[str(r['date']), f"${r['local_base']}" if r['local_base'] is not None else '(none)', f"${r['rule_min']}"]
+       for r in rows_data],
+      ['date', 'local_base', 'rule_min'],
+    )
+
+  orphans = result['orphans']
+  if orphans:
+    any_diff = True
+    n = len(orphans)
+    print(f"\n  Orphans  ({n} event{'s' if n != 1 else ''})")
+    rows = []
+    for o in orphans:
+      src = o['source']
+      src_str = f"{src['domain']}:{src.get('id','')}" if src else '(none)'
+      rows.append([str(o['date']), o['event_type'], o['title'], src_str])
+    _fmt_table(rows, ['date', 'type', 'title', 'source'])
+
+  if not any_diff:
+    print("  no differences")
+
+
+_ACTIONS: dict[str, tuple] = {
+  'fetch-reservations':   (_fetch_reservations,   'Fetch reservations from all services and write to CalDAV'),
+  'fetch-closed':         (_fetch_closed,         'Fetch per-service blocked dates and write to CalDAV'),
+  'fetch-user-cal':       (_fetch_user_cal,       'Sync Room Closed events from user_cal to the main calendar'),
+  'apply-rules':          (_apply_rules,          'Sync rule-generated discounts and closed events into the main calendar'),
+  'push-closed':          (_push_closed,          'Read local CalDAV closed events and push changes to all services'),
+  'fetch-feed':            (_fetch_feed,            'Mark days with events on an iCal feed as closed on all services'),
+  'fill-prices':          (_fill_prices,          'Create default price events for dates with no local price'),
+  'fetch-prices':         (_fetch_prices,         'Fetch service API list prices and write net prices to CalDAV'),
+  'push-prices':          (_push_prices,          'Read local CalDAV price events and push list prices to all services'),
+  'apply-discounts':      (_apply_discounts,      'Re-apply discount events to price calendar and push changed list prices'),
+  'push-busy':            (_push_busy,            'Sync Busy export calendar from local closed and reservation events'),
+  'local-sync':           (_local_sync,           'Fetch all iCal feeds, apply rules, fill prices, and sync Busy — no service API calls'),
+  'local-refresh':        (_local_refresh,        'Re-write all local Room calendar events (prices, closed, discounts, reservations)'),
+  'diff':                 (_diff,                 'Compare local CalDAV data to live service APIs and report all differences (read-only)'),
+}
+
+
+def main() -> None:
+  parser = argparse.ArgumentParser(
+    prog='room-cal',
+    description=__doc__,
+    formatter_class=argparse.RawDescriptionHelpFormatter,
+  )
+  parser.add_argument(
+    'action', choices=list(_ACTIONS), metavar='ACTION',
+    help='Action to run: ' + ', '.join(_ACTIONS),
+  )
+  parser.add_argument('start', type=_date, help='Start date (YYYY-MM-DD)')
+  parser.add_argument('end', type=_date, nargs='?', help='End date inclusive (default: same as start)')
+  parser.add_argument(
+    '--feed-name', default='Fire', metavar='NAME',
+    help='iCal feed name for fetch-feed (default: Fire)',
+  )
+  parser.add_argument(
+    '--title', default=None, metavar='TITLE',
+    help='Only close dates for feed events whose summary exactly matches TITLE',
+  )
+  parser.add_argument(
+    '--pipeline-name', default=None, metavar='PIPELINE',
+    help='Pipeline name for apply-rules (default: all pipelines)',
+  )
+  parser.add_argument(
+    '--service', default=None, metavar='SERVICE',
+    help='Service key to use for fetch-prices (default: first available)',
+  )
+  parser.add_argument(
+    '--weekday', default=None, metavar='PRICE',
+    help='Weekday net price override for fill-prices',
+  )
+  parser.add_argument(
+    '--weekend', default=None, metavar='PRICE',
+    help='Weekend net price override for fill-prices',
+  )
+  args = parser.parse_args()
+
+  end = args.end or args.start
+  if end < args.start:
+    parser.error(f"end date {end} is before start date {args.start}")
+  from room_cal.service_api.types import ServiceAuthError
+  fn, _ = _ACTIONS[args.action]
+  print(f"{args.action} {args.start} – {end}")
+  try:
+    asyncio.run(fn(args.start, end, args))
+  except ServiceAuthError as e:
+    print(f"  error: {e}")
+    raise SystemExit(1)

+ 89 - 0
room_cal/actions/apply_discounts.py

@@ -0,0 +1,89 @@
+import asyncio
+from datetime import date, timedelta
+
+from room_cal.discount import Discount, DiscountType
+from room_cal.pricing import CalPrice
+from room_cal.reservations import Reservation
+from room_cal._utils import safe_json_loads
+
+ACTION_SPEC = ('apply-discounts', [])
+
+
+def _price_is_current(stored: dict, base: int, discount_rec) -> bool:
+  """True if the stored price event already reflects this base/discount combination."""
+  base = round(base)
+  discount_amount = 0
+  if discount_rec:
+    if discount_rec.type == DiscountType.FLAT:
+      discount_amount = int(discount_rec.value)
+    else:
+      discount_amount = discount_rec.value * base / 100
+  expected_net = round(base - discount_amount)
+  if stored.get('net') != expected_net:
+    return False
+  if stored.get('base', stored.get('net')) != base:
+    return False
+  return stored.get('discount_value') == (discount_rec.value if discount_rec else None)
+
+
+async def apply_discounts(
+  start_date: date,
+  end_date: date,
+) -> list[dict]:
+  """Re-apply (or remove) discounts across a date range (local calendar only).
+
+  For each day that has a price event, re-calls CalPrice.set with the stored
+  base price so the discount module is consulted and derived prices recomputed.
+  Skips reserved days — their price events record actual booking earnings.
+  Skips days whose stored prices already match the current discount state.
+
+  Returns list of {date, old_prices, new_prices} for every date where prices changed.
+  """
+  discount = Discount()
+  cal = CalPrice()
+
+  price_events, res_events = await asyncio.gather(
+    cal.list(start_date, end_date),
+    Reservation().list(start_date, end_date),
+  )
+  discount_by_date = await discount.get_range(start_date, end_date)
+
+  booked: set[date] = set()
+  for e in res_events:
+    last_night = e['end'].date() - timedelta(days=1)
+    d = e['start'].date()
+    while d <= last_night:
+      booked.add(d)
+      d += timedelta(days=1)
+
+  to_update: list[tuple] = []
+  for e in price_events:
+    target = e['start'].date()
+    if target in booked:
+      continue
+    prices_before = safe_json_loads(e['x_data'])
+    if prices_before is None:
+      continue
+    base = prices_before.get('base') or prices_before.get('net')
+    if base is None:
+      continue
+    discount_rec = discount_by_date.get(target)
+    if _price_is_current(prices_before, base, discount_rec):
+      continue
+    to_update.append((e, base, discount_rec, prices_before))
+
+  results: list[dict] = []
+  if to_update:
+    new_prices_list = await asyncio.gather(*[
+      cal.set(e['start'].date(), base, existing=e, discount_rec=dr)
+      for e, base, dr, _ in to_update
+    ])
+    for (e, base, dr, prices_before), new_prices in zip(to_update, new_prices_list):
+      service_keys = list(new_prices.service_prices)
+      results.append({
+        'date': e['start'].date(),
+        'old_prices': {k: prices_before.get(k) for k in service_keys},
+        'new_prices': dict(new_prices.service_prices),
+      })
+
+  return results

+ 114 - 0
room_cal/actions/apply_rules.py

@@ -0,0 +1,114 @@
+import asyncio
+import dataclasses
+from datetime import date, timedelta
+
+from room_cal.closed import Closed
+from room_cal.discount import Discount
+from room_cal._utils import drop_stale_events, iter_dates, safe_json_loads
+import room_cal.rules as _rules
+
+ACTION_SPEC = ('apply-rules', ['pipeline_name'])
+
+# Price rules must run after discounts are written so CalPrice.set picks up current discounts.
+_TYPE_ORDER = {'closed': 0, 'discount': 1, 'price': 2}
+
+
+async def apply_rules(
+  start_date: date,
+  end_date: date,
+  *,
+  pipeline_name: str | None = None,
+) -> dict:
+  """Sync rule-generated discounts, closed events, and price floors into the main calendar.
+
+  Runs all configured pipelines, or only the named one if pipeline_name is given.
+  Pipelines run in type order: closed → discount → price, so price floors see
+  current discounts when computing net prices.
+  Always calls apply_discounts afterward so prices reflect current discount state.
+
+  Returns {pipeline_name: [date, ...]}.
+  """
+  names = [pipeline_name] if pipeline_name else _rules.list_pipelines()
+  if pipeline_name and pipeline_name not in _rules.list_pipelines():
+    raise ValueError(f"Unknown pipeline: {pipeline_name!r}")
+
+  names = sorted(names, key=lambda n: _TYPE_ORDER.get(_rules.pipeline_type(n), 99))
+
+  discount_cal = Discount()
+  closed_cal = Closed()
+  results: dict[str, list[date]] = {}
+
+  for name in names:
+    rule_type = _rules.pipeline_type(name)
+    pipeline_source = {'domain': 'rules', 'name': 'Rule =>', 'id': name}
+
+    if rule_type == 'closed':
+      existing_closed = await closed_cal.list(start_date, end_date)
+      generated = await _rules.run_pipeline(name, start_date, end_date, initial_data=await closed_cal.get_range(start_date, end_date, events=existing_closed))
+      to_close = {d: rec for d, rec in generated.items() if rec.services}
+      await drop_stale_events(existing_closed, set(to_close), domain='rules', id_=name, cal=closed_cal)
+      closed_by_date = {e['start'].date(): e for e in existing_closed}
+
+      async def _write_closed(d):
+        rec = dataclasses.replace(to_close[d], source=to_close[d].source or pipeline_source)
+        await closed_cal.set(d, rec, existing=closed_by_date.get(d))
+        return d
+
+      results[name] = list(await asyncio.gather(*[_write_closed(d) for d in sorted(to_close)]))
+
+    elif rule_type == 'price':
+      from room_cal.pricing import CalPrice
+      from room_cal.reservations import Reservation
+
+      generated = await _rules.run_pipeline(name, start_date, end_date)
+
+      price_cal = CalPrice()
+      price_events, res_events = await asyncio.gather(
+        price_cal.list(start_date, end_date),
+        Reservation().list(start_date, end_date),
+      )
+
+      booked: set[date] = set()
+      for e in res_events:
+        last_night = e['end'].date() - timedelta(days=1)
+        for d in iter_dates(e['start'].date(), last_night):
+          booked.add(d)
+
+      price_by_date: dict[date, tuple] = {}
+      for e in price_events:
+        d = e['start'].date()
+        xd = safe_json_loads(e['x_data'])
+        if xd:
+          base = xd.get('base') or xd.get('net')
+          price_by_date[d] = (base, e)
+
+      async def _write_price(d):
+        if d in booked:
+          return None
+        rec = generated[d]
+        current_base, existing_event = price_by_date.get(d, (None, None))
+        if current_base is None or current_base < rec.min_base:
+          await price_cal.set(d, rec.min_base, existing=existing_event)
+          return d
+        return None
+
+      gathered = await asyncio.gather(*[_write_price(d) for d in sorted(generated)])
+      results[name] = [d for d in gathered if d is not None]
+
+    else:
+      existing_discounts = await discount_cal.list(start_date, end_date)
+      generated = await _rules.run_pipeline(name, start_date, end_date, initial_data=await discount_cal.get_range(start_date, end_date, events=existing_discounts))
+      await drop_stale_events(existing_discounts, set(generated), domain='rules', id_=name, cal=discount_cal)
+      discount_by_date = {e['start'].date(): e for e in existing_discounts}
+
+      async def _write_discount(d):
+        rec = dataclasses.replace(generated[d], source=generated[d].source or pipeline_source)
+        await discount_cal.set(d, rec, existing=discount_by_date.get(d))
+        return d
+
+      results[name] = list(await asyncio.gather(*[_write_discount(d) for d in sorted(generated)]))
+
+  from room_cal.actions.apply_discounts import apply_discounts
+  await apply_discounts(start_date, end_date)
+
+  return results

+ 481 - 0
room_cal/actions/diff.py

@@ -0,0 +1,481 @@
+import asyncio
+from datetime import date, timedelta
+from typing import TYPE_CHECKING
+
+from room_cal.cal import get_calendar
+from room_cal.closed import Closed
+from room_cal.discount import Discount
+from room_cal.pricing.price_calendar import CalPrice
+from room_cal.reservations import Reservation
+from room_cal.user_cal import UserCal
+from room_cal._utils import (
+  safe_json_loads, iter_dates, filter_events, services as _services,
+  get_config, event_dates, registered_domains, local_today,
+)
+import room_cal._api_source  # ensures 'api' is registered
+import room_cal.rules  # ensures 'rules' is registered
+
+if TYPE_CHECKING:
+  from room_cal.service_api import ServiceAPI
+
+ACTION_SPEC = ('diff', [])
+
+_PRICE_PREFIX = 'Price: $'
+
+
+async def diff(
+  start_date: date,
+  end_date: date,
+  *,
+  api: 'ServiceAPI | None' = None,
+) -> dict:
+  """Compare the Room calendar to all data sources.
+
+  Returns:
+    prices          — Room Price events vs service_api list prices
+    closed          — Room Closed events vs service_api availability
+    reservations    — Room Guest events vs service_api reservations
+    user_cal_closed — Room Closed (user_cal-sourced) vs user_cal Room Closed events
+    ical_feeds_closed     — {feed_name: [...]} Room Closed (feed-sourced) vs iCal feed content
+    rule_discounts        — {pipeline_name: [...]} Room Discount (rule-sourced) vs rule output
+    rule_closed           — {pipeline_name: [...]} Room Closed (rule-sourced) vs rule output
+    orphans         — Room Closed/Discount events with missing or unrecognized source
+
+  No writes are performed.
+  """
+  from room_cal.service_api import ServiceAPI as _ServiceAPI
+  if api is None:
+    api = _ServiceAPI()
+  results = await asyncio.gather(
+    _diff_prices(start_date, end_date, api),
+    _diff_closed(start_date, end_date, api),
+    _diff_reservations(start_date, end_date, api),
+    _diff_user_cal_closed(start_date, end_date),
+    _diff_ical_feeds_closed(start_date, end_date),
+    _diff_rule_discounts(start_date, end_date),
+    _diff_rule_closed(start_date, end_date),
+    _diff_rule_prices(start_date, end_date),
+    _diff_orphans(start_date, end_date),
+  )
+  keys = ['prices', 'closed', 'reservations', 'user_cal_closed', 'ical_feeds_closed', 'rule_discounts', 'rule_closed', 'rule_prices', 'orphans']
+  return dict(zip(keys, results))
+
+
+# ── prices ────────────────────────────────────────────────────────────────────
+
+async def _diff_prices(
+  start_date: date,
+  end_date: date,
+  api: 'ServiceAPI',
+) -> list[dict]:
+  """Room Price: events vs service_api list prices.
+
+  Each row: {'date', 'net': float|None, 'per_service': {svc: {'local', 'remote'}}}
+  """
+  from room_cal.reservations import Reservation
+
+  cal = get_calendar()
+  events, api_prices, res_events = await asyncio.gather(
+    cal.fetch_date_range(start_date, end_date),
+    api.get_prices_range(start_date, end_date),
+    Reservation().list(start_date, end_date),
+  )
+
+  from room_cal.pricing.price_calendar import PriceData
+
+  svc_keys = [s.key for s in _services()]
+  cal_prices: dict[str, PriceData] = {}
+  for e in filter_events(events, _PRICE_PREFIX, all_day=True):
+    d = safe_json_loads(e['x_data'])
+    if d is not None:
+      cal_prices[e['start'].date().isoformat()] = PriceData.from_dict(d, svc_keys)
+
+
+  booked_svc_by_date: dict[date, str] = {}
+  for e in res_events:
+    xd = safe_json_loads(e['x_data'])
+    if xd:
+      svc = xd.get('service')
+      if svc:
+        last_night = e['end'].date() - timedelta(days=1)
+        for d in iter_dates(e['start'].date(), last_night):
+          booked_svc_by_date[d] = svc
+
+  today = local_today()
+  results: list[dict] = []
+  for ds in (d.isoformat() for d in iter_dates(start_date, end_date)):
+    d = date.fromisoformat(ds)
+    is_past = d < today
+    local = cal_prices.get(ds)
+    remote = api_prices.get(ds, {})
+    has_diff = False
+    per_service: dict[str, dict] = {}
+    for svc in svc_keys:
+      lp = local.service_prices.get(svc) if local else None
+      rp = remote.get(svc)
+      if lp is None and rp is None:
+        continue
+      if svc == 'booking' and is_past and rp is None:
+        continue
+      booked_svc = booked_svc_by_date.get(d)
+      if booked_svc is not None and svc != booked_svc:
+        continue
+      per_service[svc] = {'local': lp, 'remote': rp}
+      if lp is None or rp is None or lp != rp:
+        has_diff = True
+    if has_diff:
+      results.append({
+        'date': d,
+        'net': local.net if local else None,
+        'per_service': per_service,
+      })
+  return results
+
+
+# ── closed (service_api) ──────────────────────────────────────────────────────
+
+async def _diff_closed(
+  start_date: date,
+  end_date: date,
+  api: 'ServiceAPI',
+) -> list[dict]:
+  """Room Closed events vs service_api availability.
+
+  Each row: {'date', 'local': list[str]|None, 'remote': list[str]|None}
+  """
+  closed_cal = Closed()
+  cal_events = await closed_cal.list(start_date, end_date)
+  cal_by_date: dict[str, list[str]] = {}
+  for e in cal_events:
+    d = safe_json_loads(e['x_data'])
+    if d is not None:
+      cal_by_date[e['start'].date().isoformat()] = sorted(d.get('closed_services', []))
+
+  api_by_date = await api.get_closed_by_date(start_date, end_date)
+  api_by_date = {ds: sorted(svcs) for ds, svcs in api_by_date.items()}
+
+  today = local_today()
+  all_dates = sorted(set(cal_by_date) | set(api_by_date))
+  results: list[dict] = []
+  for ds in all_dates:
+    local = cal_by_date.get(ds)
+    remote = api_by_date.get(ds)
+    if date.fromisoformat(ds) < today and (remote is None or 'booking' not in remote):
+      local_cmp = [s for s in (local or []) if s != 'booking'] or None
+      remote_cmp = [s for s in (remote or []) if s != 'booking'] or None
+    else:
+      local_cmp, remote_cmp = local, remote
+    if local_cmp != remote_cmp:
+      results.append({'date': date.fromisoformat(ds), 'local': local, 'remote': remote})
+  return results
+
+
+# ── reservations ──────────────────────────────────────────────────────────────
+
+async def _diff_reservations(
+  start_date: date,
+  end_date: date,
+  api: 'ServiceAPI',
+) -> list[dict]:
+  """Room Guest events vs service_api reservations.
+
+  Each row: {'status': 'added'|'removed'|'changed', ...}
+  """
+  res_cal = Reservation()
+  cal_events = await res_cal.list(start_date, end_date)
+  cal_by_id: dict[str, dict] = {}
+  for e in cal_events:
+    if e['start'].date() < start_date:
+      continue  # checkin before range — belongs to a prior period's scope
+    d = safe_json_loads(e['x_data'])
+    if d:
+      rid = d.get('reservation_id')
+      if rid:
+        cal_by_id[rid] = {
+          'reservation_id': rid,
+          'guest_name':     d.get('guest_name', ''),
+          'service':        d.get('service', ''),
+          'checkin_date':   e['start'].date(),
+          'checkout_date':  e['end'].date(),
+        }
+
+  service_reservations = await api.get_reservations(start_date, end_date)
+  api_by_id: dict[str, dict] = {
+    r['id']: {
+      'reservation_id': r['id'],
+      'guest_name':     r['guest_name'] or '<None>',
+      'service':        r['service'],
+      'checkin_date':   r['start_date'],
+      'checkout_date':  r['end_date'],
+    }
+    for r in service_reservations
+  }
+
+  diffs: list[dict] = []
+  for rid, api_r in sorted(api_by_id.items(), key=lambda x: x[1]['checkin_date']):
+    if rid not in cal_by_id:
+      diffs.append({'status': 'added', **api_r})
+    else:
+      cal_r = cal_by_id[rid]
+      changes = []
+      for field in ('checkin_date', 'checkout_date', 'guest_name', 'service'):
+        if cal_r[field] != api_r[field]:
+          changes.append({'field': field, 'local': cal_r[field], 'remote': api_r[field]})
+      if changes:
+        diffs.append({'status': 'changed', 'reservation_id': rid, 'changes': changes})
+
+  for rid, cal_r in sorted(cal_by_id.items(), key=lambda x: x[1]['checkin_date']):
+    if rid not in api_by_id:
+      diffs.append({'status': 'removed', **cal_r})
+
+  return diffs
+
+
+# ── closed (user_cal) ─────────────────────────────────────────────────────────
+
+async def _diff_user_cal_closed(
+  start_date: date,
+  end_date: date,
+) -> list[dict]:
+  """Room Closed events (user_cal-sourced) vs current user_cal Room Closed events.
+
+  Each row: {'date', 'in_room': bool, 'in_user_cal': bool}
+  """
+  closed_cal = Closed()
+  user_cal = UserCal()
+
+  room_events, user_cal_events = await asyncio.gather(
+    closed_cal.list(start_date, end_date),
+    user_cal.find_closed(start_date, end_date),
+  )
+
+  room_dates: set[date] = set()
+  for e in room_events:
+    xd = safe_json_loads(e['x_data'])
+    if xd and xd.get('source', {}).get('domain') == 'user_cal':
+      room_dates.add(e['start'].date())
+
+  user_cal_dates: set[date] = set()
+  for ev in user_cal_events:
+    for d in event_dates(ev, start_date, end_date):
+      user_cal_dates.add(d)
+
+  all_dates = sorted(room_dates | user_cal_dates)
+  return [
+    {'date': d, 'in_room': d in room_dates, 'in_user_cal': d in user_cal_dates}
+    for d in all_dates
+    if (d in room_dates) != (d in user_cal_dates)
+  ]
+
+
+# ── closed (iCal feeds) ───────────────────────────────────────────────────────
+
+async def _diff_ical_feeds_closed(
+  start_date: date,
+  end_date: date,
+) -> dict[str, list[dict]]:
+  """Room Closed events (feed-sourced) vs current iCal feed content.
+
+  Returns {feed_name: [{'date', 'in_room': bool, 'in_feed': bool}]}
+  Only includes feeds with at least one difference.
+  """
+  from room_cal.ical_feeds import fetch_calendar
+
+  entries = get_config().calendars
+  if not entries:
+    return {}
+
+  closed_cal = Closed()
+  room_events = await closed_cal.list(start_date, end_date)
+
+  async def _check_feed(entry) -> tuple[str, list[dict]]:
+    feed_name = entry.name
+    room_dates: set[date] = set()
+    for e in room_events:
+      xd = safe_json_loads(e['x_data'])
+      if xd:
+        src = xd.get('source', {})
+        if src.get('domain') == 'ical_feeds' and src.get('id') == feed_name:
+          room_dates.add(e['start'].date())
+
+    feed_events = await fetch_calendar(entry)
+    feed_dates: set[date] = set()
+    for ev in feed_events:
+      for d in event_dates(ev, start_date, end_date):
+        feed_dates.add(d)
+
+    all_dates = sorted(room_dates | feed_dates)
+    diffs = [
+      {'date': d, 'in_room': d in room_dates, 'in_ical_feeds': d in feed_dates}
+      for d in all_dates
+      if (d in room_dates) != (d in feed_dates)
+    ]
+    return feed_name, diffs
+
+  results = await asyncio.gather(*[_check_feed(e) for e in entries])
+  return {name: diffs for name, diffs in results if diffs}
+
+
+# ── discounts (rules) ────────────────────────────────────────────────────────
+
+async def _diff_rule_discounts(
+  start_date: date,
+  end_date: date,
+) -> dict[str, list[dict]]:
+  """Room Discount events (rule-sourced) vs what each discount pipeline generates.
+
+  Returns {pipeline_name: [{'date', 'local', 'rule'}]}, only pipelines with diffs.
+  """
+  disc_cal = Discount()
+  room_events = await disc_cal.list(start_date, end_date)
+
+  results: dict[str, list[dict]] = {}
+  for name in room_cal.rules.list_pipelines(type_='discount'):
+    room_by_date: dict[date, dict] = {}
+    for e in room_events:
+      xd = safe_json_loads(e['x_data'])
+      if xd:
+        src = xd.get('source', {})
+        if src.get('domain') == 'rules' and src.get('id') == name:
+          room_by_date[e['start'].date()] = {'type': xd['type'], 'value': xd['value']}
+
+    pipeline_out = await room_cal.rules.run_pipeline(name, start_date, end_date)
+    rule_by_date = {d: {'type': rec.type, 'value': rec.value} for d, rec in pipeline_out.items()}
+
+    all_dates = sorted(set(room_by_date) | set(rule_by_date))
+    diffs = [
+      {'date': d, 'local': room_by_date.get(d), 'rule': rule_by_date.get(d)}
+      for d in all_dates
+      if room_by_date.get(d) != rule_by_date.get(d)
+    ]
+    if diffs:
+      results[name] = diffs
+
+  return results
+
+
+# ── closed (rules) ───────────────────────────────────────────────────────────
+
+async def _diff_rule_closed(
+  start_date: date,
+  end_date: date,
+) -> dict[str, list[dict]]:
+  """Room Closed events (rule-sourced) vs what each closed pipeline generates.
+
+  Returns {pipeline_name: [{'date', 'in_room': bool, 'in_rule': bool}]}, only pipelines with diffs.
+  """
+  closed_cal = Closed()
+  room_events = await closed_cal.list(start_date, end_date)
+
+  results: dict[str, list[dict]] = {}
+  for name in room_cal.rules.list_pipelines(type_='closed'):
+    room_dates: set[date] = set()
+    for e in room_events:
+      xd = safe_json_loads(e['x_data'])
+      if xd:
+        src = xd.get('source', {})
+        if src.get('domain') == 'rules' and src.get('id') == name:
+          room_dates.add(e['start'].date())
+
+    pipeline_out = await room_cal.rules.run_pipeline(name, start_date, end_date)
+    rule_dates: set[date] = {d for d, rec in pipeline_out.items() if rec.services}
+
+    all_dates = sorted(room_dates | rule_dates)
+    diffs = [
+      {'date': d, 'in_room': d in room_dates, 'in_rule': d in rule_dates}
+      for d in all_dates
+      if (d in room_dates) != (d in rule_dates)
+    ]
+    if diffs:
+      results[name] = diffs
+
+  return results
+
+
+# ── prices (rules) ───────────────────────────────────────────────────────────
+
+async def _diff_rule_prices(
+  start_date: date,
+  end_date: date,
+) -> dict[str, list[dict]]:
+  """Room Price events vs minimum floors required by each price pipeline.
+
+  Returns {pipeline_name: [{'date', 'local_base', 'rule_min'}]}, only pipelines with diffs.
+  As-booked dates are excluded: the booking price is always authoritative.
+  """
+  price_events, res_events = await asyncio.gather(
+    CalPrice().list(start_date, end_date),
+    Reservation().list(start_date, end_date),
+  )
+
+  booked: set[date] = set()
+  for e in res_events:
+    last_night = e['end'].date() - timedelta(days=1)
+    for d in iter_dates(e['start'].date(), last_night):
+      booked.add(d)
+
+  local_base_by_date: dict[date, int] = {}
+  for e in price_events:
+    xd = safe_json_loads(e['x_data'])
+    if xd:
+      base = xd.get('base') or xd.get('net')
+      if base is not None:
+        local_base_by_date[e['start'].date()] = base
+
+  results: dict[str, list[dict]] = {}
+  for name in room_cal.rules.list_pipelines(type_='price'):
+    pipeline_out = await room_cal.rules.run_pipeline(name, start_date, end_date)
+    diffs = []
+    for d in sorted(pipeline_out):
+      if d in booked:
+        continue
+      rule_min = pipeline_out[d].min_base
+      local_base = local_base_by_date.get(d)
+      if local_base is None or local_base < rule_min:
+        diffs.append({'date': d, 'local_base': local_base, 'rule_min': rule_min})
+    if diffs:
+      results[name] = diffs
+  return results
+
+
+# ── orphans ───────────────────────────────────────────────────────────────────
+
+async def _diff_orphans(
+  start_date: date,
+  end_date: date,
+) -> list[dict]:
+  """Room Closed and Discount events with missing or unrecognized source.
+
+  Each row: {'date', 'event_type': 'closed'|'discount', 'title', 'source': dict|None}
+  """
+  events = await get_calendar().fetch_date_range(start_date, end_date)
+
+  closed_events   = filter_events(events, 'Closed: ',   all_day=True, require_x_data=False)
+  discount_events = filter_events(events, 'Discount: ', all_day=True, require_x_data=False)
+
+  orphans: list[dict] = []
+
+  for e in closed_events:
+    xd = safe_json_loads(e['x_data'])
+    domain = xd.get('source', {}).get('domain') if xd else None
+    if domain not in registered_domains():
+      orphans.append({
+        'date':       e['start'].date(),
+        'event_type': 'closed',
+        'title':      e['title'],
+        'source':     (xd.get('source') if xd else None),
+      })
+
+  for e in discount_events:
+    xd = safe_json_loads(e['x_data'])
+    domain = xd.get('source', {}).get('domain') if xd else None
+    if domain not in registered_domains():
+      orphans.append({
+        'date':       e['start'].date(),
+        'event_type': 'discount',
+        'title':      e['title'],
+        'source':     (xd.get('source') if xd else None),
+      })
+
+  orphans.sort(key=lambda x: x['date'])
+  return orphans

+ 73 - 0
room_cal/actions/fetch_closed.py

@@ -0,0 +1,73 @@
+from datetime import date
+from typing import TYPE_CHECKING
+
+from room_cal.closed import Closed, ClosedData
+from room_cal.reservations import Reservation
+from room_cal._utils import FetchResult, is_ephemeral_domain, safe_json_loads
+
+if TYPE_CHECKING:
+  from room_cal.service_api import ServiceAPI
+
+ACTION_SPEC = ('fetch-closed', [])
+
+
+async def fetch_closed(
+  start_date: date,
+  end_date: date,
+  *,
+  api: 'ServiceAPI | None' = None,
+) -> FetchResult[list[dict]]:
+  """Fetch per-service blocked dates and write them to CalDAV as 'Closed' all-day events.
+
+  Deletes any existing 'Closed' events for dates that are now open.
+  Returns FetchResult with list of {date, closed_services} for dates that have
+  closure events after sync, and a changed flag.
+  """
+  from room_cal.service_api import ServiceAPI as _ServiceAPI
+  from room_cal.actions.fetch_reservations import fetch_reservations
+  if api is None:
+    api = _ServiceAPI()
+  closed_cal = Closed()
+  res_cal = Reservation()
+
+  existing_events = await closed_cal.list(start_date, end_date)
+  existing_by_date: dict[str, dict] = {}
+  for e in existing_events:
+    ds = e['start'].date().isoformat()
+    existing_by_date[ds] = e
+
+  closed_by_date = await api.get_closed_by_date(start_date, end_date)
+
+  changed = False
+
+  for ds, event in existing_by_date.items():
+    if ds not in closed_by_date:
+      xd = safe_json_loads(event['x_data'])
+      event_domain = (xd or {}).get('source', {}).get('domain')
+      if event_domain == 'service_api' or is_ephemeral_domain(event_domain):
+        changed = True
+        await closed_cal.delete(event)
+
+  synced: list[dict] = []
+  for ds, closed_services in sorted(closed_by_date.items()):
+    day = date.fromisoformat(ds)
+    reservations_on_day = await res_cal.list(day, day)
+    if reservations_on_day:
+      res_start = min(e['start'].date() for e in reservations_on_day)
+      res_end   = max(e['end'].date()   for e in reservations_on_day)
+      await fetch_reservations(min(start_date, res_start), max(end_date, res_end), api=api)
+    else:
+      services_str = ', '.join(sorted(closed_services))
+      source = {'domain': 'service_api', 'name': 'Upstream =>', 'id': services_str}
+      existing = existing_by_date.get(ds)
+      if existing is None:
+        changed = True
+      else:
+        xd = safe_json_loads(existing['x_data'])
+        existing_services = sorted((xd or {}).get('closed_services') or [])
+        if existing_services != sorted(closed_services):
+          changed = True
+      await closed_cal.set(day, ClosedData(services=closed_services, source=source))
+    synced.append({'date': day, 'closed_services': closed_services})
+
+  return FetchResult(data=synced, changed=changed)

+ 63 - 0
room_cal/actions/fetch_feed.py

@@ -0,0 +1,63 @@
+from datetime import date
+
+from room_cal.closed import Closed, ClosedData
+from room_cal._utils import FetchResult, safe_json_loads, services as _services, get_config, event_dates, drop_stale_events
+
+ACTION_SPEC = ('fetch-feed', ['feed_name', 'title'])
+
+
+async def fetch_feed(
+  start_date: date,
+  end_date: date,
+  *,
+  feed_name: str = 'Fire',
+  title: str | None = None,
+) -> FetchResult[list[date]]:
+  """Mark days covered by events in an iCal feed as closed in the local calendar.
+
+  Adds/updates closures for dates covered by the feed, and removes closures
+  for dates that are no longer covered (verifying source domain and key before
+  deleting). Returns FetchResult with sorted list of dates that were marked
+  closed, and a changed flag.
+  If title is given, only events whose summary exactly matches that string are used.
+  """
+  from room_cal.ical_feeds import fetch_calendar
+
+  entries = get_config().calendars
+  entry = next((e for e in entries if e.name == feed_name), None)
+  if entry is None:
+    raise ValueError(f"No ical feed configured with name {feed_name!r}")
+
+  events = await fetch_calendar(entry)
+  if title is not None:
+    events = [e for e in events if e.get('title') == title]
+
+  blocked: set[date] = set()
+  for event in events:
+    for d in event_dates(event, start_date, end_date):
+      blocked.add(d)
+
+  all_services = sorted(s.key for s in _services())
+  closed_cal = Closed()
+
+  existing_closed = await closed_cal.list(start_date, end_date)
+
+  existing_covered: set[date] = set()
+  for e in existing_closed:
+    xd = safe_json_loads(e['x_data'])
+    src = (xd or {}).get('source', {})
+    if src.get('domain') == 'ical_feeds' and src.get('id') == feed_name:
+      existing_covered.add(e['start'].date())
+
+  await drop_stale_events(existing_closed, blocked, domain='ical_feeds', id_=feed_name, cal=closed_cal)
+  closed_by_date = {e['start'].date(): e for e in existing_closed}
+
+  source = {'domain': 'ical_feeds', 'name': 'Calendar =>', 'id': feed_name}
+  synced: list[date] = []
+  for d in sorted(blocked):
+    await closed_cal.set(d, ClosedData(services=all_services, source=source), existing=closed_by_date.get(d))
+    synced.append(d)
+
+  changed = existing_covered != blocked
+
+  return FetchResult(data=synced, changed=changed)

+ 102 - 0
room_cal/actions/fetch_prices.py

@@ -0,0 +1,102 @@
+import asyncio
+from datetime import date, timedelta
+from typing import TYPE_CHECKING
+
+from room_cal.discount import Discount, DiscountType
+from room_cal.pricing import CalPrice
+from room_cal.pricing.calc import list_to_net
+from room_cal.reservations import Reservation
+from room_cal._utils import FetchResult, services as _services, safe_json_loads, iter_dates
+
+if TYPE_CHECKING:
+  from room_cal.service_api import ServiceAPI
+
+ACTION_SPEC = ('fetch-prices', ['service'])
+
+
+async def fetch_prices(
+  start_date: date,
+  end_date: date,
+  *,
+  api: 'ServiceAPI | None' = None,
+  service: str | None = None,
+  booked_only: bool = False,
+) -> FetchResult[list[dict]]:
+  """Fetch per-service list prices from the API and write net prices to CalDAV.
+
+  Converts each service's list price back to a net price via the calc module, then
+  calls CalPrice.set so all derived list/retail prices are recomputed consistently.
+  Dates where all services report None (booked or unavailable) are skipped.
+  Returns FetchResult with list of {date, net} for each date written, and a changed flag.
+
+  If `service` is given, only that service's prices are used; otherwise the
+  first service with a non-None price wins.
+  If `booked_only` is True, only dates that have an active reservation are updated;
+  open days are skipped regardless of what the platform reports.
+  """
+  from room_cal.service_api import ServiceAPI as _ServiceAPI
+  if api is None:
+    api = _ServiceAPI()
+
+  api_prices = await api.get_prices_range(start_date, end_date)
+  if not api_prices:
+    return FetchResult(data=[], changed=False)
+
+  service_keys = [service] if service else [s.key for s in _services()]
+  cal = CalPrice()
+  existing_events, res_events, discount_by_date = await asyncio.gather(
+    cal.list(start_date, end_date),
+    Reservation().list(start_date, end_date),
+    Discount().get_range(start_date, end_date),
+  )
+  existing_by_date = {e['start'].date(): e for e in existing_events}
+
+  booked_svc_by_date: dict[str, str] = {}
+  for e in res_events:
+    xd = safe_json_loads(e['x_data'])
+    if xd:
+      svc = xd.get('service')
+      if svc:
+        last_night = e['end'].date() - timedelta(days=1)
+        for d in iter_dates(e['start'].date(), last_night):
+          booked_svc_by_date[d.isoformat()] = svc
+
+  changed = False
+  results: list[dict] = []
+  for ds in sorted(api_prices):
+    service_prices = api_prices[ds]
+    booked_svc = booked_svc_by_date.get(ds)
+    if booked_only and not booked_svc:
+      continue
+    keys_to_try = [booked_svc] if booked_svc and not service else service_keys
+    net = None
+    for svc in keys_to_try:
+      list_price = service_prices.get(svc)
+      if list_price is not None:
+        net = list_to_net(svc, list_price)
+        break
+    if net is None:
+      continue
+    d = date.fromisoformat(ds)
+    discount_rec = discount_by_date.get(d)
+    # The platform list price was derived from the discounted net, so reverse the
+    # local discount to recover the true base before calling CalPrice.set, which
+    # will re-apply the discount. Without this, each fetch→push cycle compounds
+    # the discount and ratchets the base price down.
+    base = net
+    if discount_rec:
+      if discount_rec.type == DiscountType.FLAT:
+        base = net + int(discount_rec.value)
+      else:
+        base = round(net / (1 - discount_rec.value / 100))
+    existing = existing_by_date.get(d)
+    if existing is None:
+      changed = True
+    else:
+      xd = safe_json_loads(existing['x_data'])
+      if (xd or {}).get('net') != net:
+        changed = True
+    await cal.set(d, base, existing=existing, discount_rec=discount_rec)
+    results.append({'date': d, 'net': net})
+
+  return FetchResult(data=results, changed=changed)

+ 157 - 0
room_cal/actions/fetch_reservations.py

@@ -0,0 +1,157 @@
+import asyncio
+from datetime import date, timedelta
+from typing import TYPE_CHECKING
+
+from room_cal.closed import Closed, ClosedData
+from room_cal.reservations import Reservation
+from room_cal.user_cal import UserCal
+from room_cal._utils import FetchResult, safe_json_loads, iter_dates, services as _services, service_name
+
+if TYPE_CHECKING:
+  from room_cal.service_api import ServiceAPI
+
+ACTION_SPEC = ('fetch-reservations', [])
+
+
+def _source_matches(stored: dict, domain: str, key: str, id_: str) -> bool:
+  return (
+    stored.get('domain') == domain
+    and stored.get('key') == key
+    and stored.get('id') == id_
+  )
+
+
+async def _remove_reservation_closures(
+  closed: Closed,
+  checkin_date: date,
+  checkout_date: date,
+  source: dict,
+) -> None:
+  events = await closed.list(checkin_date, checkout_date - timedelta(days=1))
+  for event in events:
+    d = safe_json_loads(event['x_data'])
+    stored = d.get('source', {}) if d else {}
+    if isinstance(stored, dict) and _source_matches(
+      stored, source['domain'], source['key'], source['id']
+    ):
+      await closed.delete(event)
+
+
+async def fetch_reservations(
+  start_date: date,
+  end_date: date,
+  *,
+  api: 'ServiceAPI | None' = None,
+) -> FetchResult[list[dict]]:
+  """Fetch reservations from all services and write them to the CalDAV calendar.
+
+  Calls ServiceAPI.get_reservations() for the given date range, then upserts
+  each result into CalDAV via Reservation.set(). Returns FetchResult with the
+  list of synced reservation data dicts and a changed flag.
+  """
+  from room_cal.service_api import ServiceAPI as _ServiceAPI
+  if api is None:
+    api = _ServiceAPI()
+  res_cal = Reservation()
+  closed = Closed()
+  user_cal = UserCal()
+  all_service_keys = [s.key for s in _services()]
+
+  existing_events = await res_cal.list(start_date, end_date)
+  existing_by_id: dict[str, tuple[dict, dict]] = {}
+  for e in existing_events:
+    if e['start'].date() < start_date:
+      continue  # checkin before range — belongs to a prior period's scope
+    d = safe_json_loads(e['x_data'])
+    if d:
+      rid = d.get('reservation_id')
+      if rid:
+        existing_by_id[rid] = (e, d)
+
+  service_reservations = await api.get_reservations(start_date, end_date)
+  service_ids = {r['id'] for r in service_reservations}
+
+  changed = False
+
+  for rid, (event, data) in existing_by_id.items():
+    if rid not in service_ids:
+      changed = True
+      await res_cal.delete(event)
+      checkin_date = event['start'].date()
+      checkout_date = event['end'].date()
+      source = {'domain': 'reservations', 'name': service_name(data['service']), 'key': data['service'], 'id': rid}
+      await _remove_reservation_closures(closed, checkin_date, checkout_date, source)
+
+  synced: list[dict] = []
+  res_set_tasks = []
+  closure_removal_tasks = []
+  closed_set_inputs: list[tuple[date, ClosedData]] = []
+
+  for r in service_reservations:
+    data = {
+      'checkin_date':   r['start_date'],
+      'checkout_date':  r['end_date'],
+      'guest_name':     r['guest_name'] or '<None>',
+      'service':        r['service'],
+      'reservation_id': r['id'],
+      'service_data':   r.get('service_data') or {},
+      'chat_id':        r.get('chat_id'),
+      'door_code':      r.get('door_code'),
+    }
+    synced.append(data)
+    other_services = [s for s in all_service_keys if s != r['service']]
+    source = {'domain': 'reservations', 'name': service_name(r['service']), 'key': r['service'], 'id': r['id']}
+    stay_end = r['end_date'] - timedelta(days=1)
+    res_set_tasks.append(res_cal.set(data))
+
+    existing = existing_by_id.get(r['id'])
+    if existing:
+      old_event, _ = existing
+      old_checkin = old_event['start'].date()
+      old_checkout = old_event['end'].date()
+      if old_checkin != r['start_date'] or old_checkout != r['end_date']:
+        changed = True
+        closure_removal_tasks.append(
+          _remove_reservation_closures(closed, old_checkin, old_checkout, source)
+        )
+    else:
+      changed = True
+
+    for day in iter_dates(r['start_date'], stay_end):
+      closed_set_inputs.append((day, ClosedData(services=other_services, source=source)))
+
+  await asyncio.gather(*closure_removal_tasks)
+  await asyncio.gather(*res_set_tasks)
+
+  if service_reservations:
+    eff_closed_start = min(start_date, min(r['start_date'] for r in service_reservations))
+    eff_closed_end   = max(end_date,   max(r['end_date']   for r in service_reservations))
+  else:
+    eff_closed_start, eff_closed_end = start_date, end_date
+  existing_closed = await closed.list(eff_closed_start, eff_closed_end)
+  closed_by_date = {e['start'].date(): e for e in existing_closed}
+  await asyncio.gather(*[
+    closed.set(day, data, existing=closed_by_date.get(day))
+    for day, data in closed_set_inputs
+  ])
+
+  ucal_events = await user_cal.find_services(start_date, end_date)
+  ucal_by_id: dict[str, dict] = {}
+  for e in ucal_events:
+    d = safe_json_loads(e['x_data'])
+    if d:
+      rid = d.get('reservation_id')
+      if rid:
+        ucal_by_id[rid] = e
+
+  await asyncio.gather(*[
+    user_cal.delete(event)
+    for rid, event in ucal_by_id.items()
+    if rid not in service_ids
+  ])
+  await asyncio.gather(*[
+    user_cal.set(r['service'], r['id'], r['start_date'], r['end_date'])
+    for r in service_reservations
+  ])
+
+  return FetchResult(data=synced, changed=changed)

+ 52 - 0
room_cal/actions/fetch_user_cal.py

@@ -0,0 +1,52 @@
+from datetime import date
+
+from room_cal.closed import Closed, ClosedData
+from room_cal.user_cal import UserCal, DEFAULT_CALENDAR_NAME as USER_CAL_DEFAULT
+from room_cal._utils import FetchResult, safe_json_loads, services as _services, event_dates, drop_stale_events
+
+ACTION_SPEC = ('fetch-user-cal', [])
+
+_USER_CAL_SOURCE = 'user_cal'
+
+
+async def fetch_user_cal(
+  start_date: date,
+  end_date: date,
+) -> FetchResult[dict]:
+  """Sync 'Room Closed' events from user_cal into the main calendar.
+
+  Creates/updates Closed entries in the local calendar.
+  Removes Closed entries previously written from user_cal that are no longer covered.
+
+  Returns FetchResult with {'closed': [date, ...]} and a changed flag.
+  """
+  user_cal = UserCal()
+  closed_cal = Closed()
+  all_services = sorted(s.key for s in _services())
+  source = {'domain': _USER_CAL_SOURCE, 'name': 'User =>', 'id': USER_CAL_DEFAULT}
+
+  blocked: set[date] = set()
+  for ev in await user_cal.find_closed(start_date, end_date):
+    for d in event_dates(ev, start_date, end_date):
+      blocked.add(d)
+
+  existing_closed = await closed_cal.list(start_date, end_date)
+
+  existing_covered: set[date] = set()
+  for e in existing_closed:
+    xd = safe_json_loads(e['x_data'])
+    src = (xd or {}).get('source', {})
+    if src.get('domain') == _USER_CAL_SOURCE and src.get('id') == USER_CAL_DEFAULT:
+      existing_covered.add(e['start'].date())
+
+  await drop_stale_events(existing_closed, blocked, domain=_USER_CAL_SOURCE, id_=USER_CAL_DEFAULT, cal=closed_cal)
+  closed_by_date = {e['start'].date(): e for e in existing_closed}
+
+  synced_closed: list[date] = []
+  for d in sorted(blocked):
+    await closed_cal.set(d, ClosedData(services=all_services, source=source), existing=closed_by_date.get(d))
+    synced_closed.append(d)
+
+  changed = existing_covered != blocked
+
+  return FetchResult(data={'closed': synced_closed}, changed=changed)

+ 36 - 0
room_cal/actions/fill_prices.py

@@ -0,0 +1,36 @@
+from datetime import date
+
+from room_cal.pricing import CalPrice
+from room_cal._utils import iter_dates, get_config
+
+ACTION_SPEC = ('fill-prices', ['weekday', 'weekend'])
+
+
+async def fill_prices(
+  start_date: date,
+  end_date: date,
+  *,
+  weekday: int | None = None,
+  weekend: int | None = None,
+) -> list[dict]:
+  """Create default price events for dates in the range that have no price set.
+
+  weekday and weekend override the defaults from config.json global.prices.
+  Returns list of {date, net} for each date written.
+  """
+  cal = CalPrice()
+  existing_events = await cal.list(start_date, end_date)
+  existing_dates = {e['start'].date() for e in existing_events}
+
+  cfg = get_config().global_.prices
+  wd = weekday if weekday is not None else cfg.weekday
+  we = weekend if weekend is not None else cfg.weekend
+
+  results: list[dict] = []
+  for d in iter_dates(start_date, end_date):
+    if d in existing_dates:
+      continue
+    base = we if d.weekday() in (4, 5) else wd
+    await cal.set(d, base)
+    results.append({'date': d, 'net': base})
+  return results

+ 109 - 0
room_cal/actions/local_refresh.py

@@ -0,0 +1,109 @@
+from datetime import date
+
+from room_cal.pricing import CalPrice
+from room_cal.closed import Closed, ClosedData
+from room_cal.discount import Discount, DiscountData, DiscountType
+from room_cal.reservations import Reservation
+from room_cal._utils import safe_json_loads
+
+ACTION_SPEC = ('local-refresh', [])
+
+
+async def refresh_prices(start: date, end: date) -> None:
+  cal = CalPrice()
+  discount = Discount()
+  price_events = await cal.list(start, end)
+  discount_by_date = await discount.get_range(start, end)
+  found = 0
+  for e in price_events:
+    d = e['start'].date()
+    prices = safe_json_loads(e['x_data'])
+    base = prices.get('base') if prices else None
+    if base is None:
+      continue
+    await cal.set(d, base, existing=e, discount_rec=discount_by_date.get(d))
+    print(f"  price {d}: ${base}")
+    found += 1
+  print(f"  {found} price event(s) refreshed")
+
+
+async def refresh_closed(start: date, end: date) -> None:
+  closed = Closed()
+  events = await closed.list(start, end)
+  found = 0
+  for e in events:
+    d = e['start'].date()
+    xd = safe_json_loads(e['x_data'])
+    if xd is None:
+      continue
+    source = xd.get('source') or {}
+    if source.get('domain') == 'rules':
+      continue  # handled by refresh_rules
+    services = xd.get('closed_services', [])
+    if not services:
+      continue
+    await closed.set(d, ClosedData(services=services, source=source), existing=e)
+    print(f"  closed {d}: {', '.join(services)}")
+    found += 1
+  print(f"  {found} closed event(s) refreshed")
+
+
+async def refresh_discounts(start: date, end: date) -> None:
+  discount = Discount()
+  events = await discount.list(start, end)
+  found = 0
+  for e in events:
+    d = e['start'].date()
+    xd = safe_json_loads(e['x_data'])
+    if xd is None:
+      continue
+    source = xd.get('source') or {}
+    if source.get('domain') == 'rules':
+      continue  # handled by refresh_rules
+    dtype = xd.get('type')
+    value = xd.get('value')
+    if dtype is None or value is None:
+      continue
+    await discount.set(d, DiscountData(type=DiscountType(dtype), value=value, source=source), existing=e)
+    print(f"  discount {d}: {dtype} {value}")
+    found += 1
+  print(f"  {found} discount event(s) refreshed")
+
+
+async def refresh_reservations(start: date, end: date) -> None:
+  reservations = Reservation()
+  events = await reservations.list(start, end)
+  found = 0
+  for e in events:
+    xd = safe_json_loads(e['x_data'])
+    if xd is None:
+      continue
+    checkin_date = e['start'].date()
+    checkout_date = e['end'].date()
+    data = {
+      'checkin_date': checkin_date,
+      'checkout_date': checkout_date,
+      **xd,
+    }
+    await reservations.set(data)
+    print(f"  reservation {checkin_date} – {checkout_date}: {xd.get('guest_name', '')}")
+    found += 1
+  print(f"  {found} reservation(s) refreshed")
+
+
+async def refresh_rules(start: date, end: date) -> None:
+  from room_cal.actions.apply_rules import apply_rules
+  results = await apply_rules(start, end)
+  for name, dates in results.items():
+    for d in dates:
+      print(f"  rule {name}: {d}")
+  total = sum(len(v) for v in results.values())
+  print(f"  {total} rule event(s) refreshed")
+
+
+async def local_refresh(start: date, end: date) -> None:
+  await refresh_rules(start, end)
+  await refresh_closed(start, end)
+  await refresh_discounts(start, end)
+  await refresh_prices(start, end)
+  await refresh_reservations(start, end)

+ 39 - 0
room_cal/actions/local_sync.py

@@ -0,0 +1,39 @@
+from datetime import date
+
+from room_cal._utils import get_config
+
+ACTION_SPEC = ('local-sync', [])
+
+
+async def local_sync(start_date: date, end_date: date) -> None:
+  """Run all local-only sync operations in dependency order.
+
+  1. iCal feeds + user_cal → Closed events (inputs for closed-type rules)
+  2. fill_prices → price events must exist before apply_discounts runs
+  3. apply_rules → rule-based closed/discounts, then apply_discounts over all prices
+  4. push_busy → reads final closed + reservation state
+
+  No service API calls.
+  """
+  from room_cal.actions.fetch_feed import fetch_feed
+  from room_cal.actions.fetch_user_cal import fetch_user_cal
+  from room_cal.actions.fill_prices import fill_prices
+  from room_cal.actions.apply_rules import apply_rules
+  from room_cal.actions.push_busy import push_busy
+
+  for entry in get_config().calendars:
+    result = await fetch_feed(start_date, end_date, feed_name=entry.name)
+    print(f"  feed {entry.name!r}: {len(result.data)} date(s) closed" + (" (changed)" if result.changed else ""))
+
+  uc_result = await fetch_user_cal(start_date, end_date)
+  print(f"  user_cal: {len(uc_result.data['closed'])} date(s) closed" + (" (changed)" if uc_result.changed else ""))
+
+  filled = await fill_prices(start_date, end_date)
+  print(f"  fill-prices: {len(filled)} price(s) created")
+
+  rule_results = await apply_rules(start_date, end_date)
+  total = sum(len(v) for v in rule_results.values())
+  print(f"  rules: {total} event(s) synced")
+
+  busy_result = await push_busy(start_date, end_date)
+  print(f"  push-busy: {len(busy_result.data['busy'])} date(s) busy" + (" (changed)" if busy_result.changed else ""))

+ 54 - 0
room_cal/actions/push_busy.py

@@ -0,0 +1,54 @@
+import asyncio
+from datetime import date, timedelta
+
+from room_cal.busy import BusyCal
+from room_cal.closed import Closed
+from room_cal.reservations import Reservation
+from room_cal._utils import FetchResult, iter_dates
+
+ACTION_SPEC = ('push-busy', [])
+
+
+async def push_busy(start_date: date, end_date: date) -> FetchResult[dict]:
+  """Sync 'Busy' events to the export calendar from local Room calendar data.
+
+  A day is busy when it has a Closed event or falls within a guest reservation.
+  Creates 'Busy' all-day events for busy dates; removes them for days no longer busy.
+  Returns FetchResult with {'busy': [date, ...]} and a changed flag.
+  """
+  closed_cal = Closed()
+  res_cal = Reservation()
+  busy_cal = BusyCal()
+
+  closed_events, res_events = await asyncio.gather(
+    closed_cal.list(start_date, end_date),
+    res_cal.list(start_date, end_date),
+  )
+
+  busy_dates: set[date] = set()
+
+  for e in closed_events:
+    busy_dates.add(e['start'].date())
+
+  for e in res_events:
+    checkin = e['start'].date()
+    last_night = e['end'].date() - timedelta(days=1)
+    for d in iter_dates(checkin, last_night):
+      if start_date <= d <= end_date:
+        busy_dates.add(d)
+
+  existing = await busy_cal.list(start_date, end_date)
+  existing_by_date: dict[date, dict] = {e['start'].date(): e for e in existing}
+  existing_dates = set(existing_by_date)
+
+  to_add = sorted(busy_dates - existing_dates)
+  to_remove = sorted(existing_dates - busy_dates)
+
+  for d in to_remove:
+    await busy_cal.delete(existing_by_date[d])
+
+  for d in to_add:
+    await busy_cal.set(d, existing=None)
+
+  changed = bool(to_add or to_remove)
+  return FetchResult(data={'busy': sorted(busy_dates)}, changed=changed)

+ 56 - 0
room_cal/actions/push_closed.py

@@ -0,0 +1,56 @@
+from datetime import date
+from typing import TYPE_CHECKING
+
+from room_cal.closed import Closed
+from room_cal._utils import safe_json_loads
+
+if TYPE_CHECKING:
+  from room_cal.service_api import ServiceAPI
+
+ACTION_SPEC = ('push-closed', [])
+
+
+async def push_closed(
+  start_date: date,
+  end_date: date,
+  *,
+  api: 'ServiceAPI | None' = None,
+) -> list[dict]:
+  """Read local 'Closed' CalDAV events and push them to the service API.
+
+  Dates with a local Closed event are blocked on all services.
+  Dates without a local Closed event that are blocked in the API are opened.
+  Skips booked dates (handled by set_available internally).
+  Returns list of {date, action} for every date changed ('closed' or 'opened').
+  """
+  from room_cal.service_api import ServiceAPI as _ServiceAPI
+  if api is None:
+    api = _ServiceAPI()
+  closed_cal = Closed()
+
+  cal_events = await closed_cal.list(start_date, end_date)
+  cal_by_date: set[str] = set()
+  for e in cal_events:
+    ds = e['start'].date().isoformat()
+    d = safe_json_loads(e['x_data'])
+    if d is None or d.get('closed_services'):
+      cal_by_date.add(ds)
+
+  api_by_date = await api.get_closed_by_date(start_date, end_date)
+
+  # Always push locally-closed dates: set_availability skips if already fully closed
+  # (checks both available and rooms_to_sell), catching hidden=1/rooms_to_sell=1 drift.
+  to_close = sorted(cal_by_date)
+  to_open  = sorted(set(api_by_date) - cal_by_date)
+
+  results: list[dict] = []
+  for ds in to_close:
+    await api.set_available(date.fromisoformat(ds), False)
+    results.append({'date': date.fromisoformat(ds), 'action': 'closed'})
+
+  for ds in to_open:
+    await api.set_available(date.fromisoformat(ds), True)
+    results.append({'date': date.fromisoformat(ds), 'action': 'opened'})
+
+  results.sort(key=lambda r: r['date'])
+  return results

+ 53 - 0
room_cal/actions/push_prices.py

@@ -0,0 +1,53 @@
+from datetime import date
+from typing import TYPE_CHECKING
+
+from room_cal.cal import Calendar
+from room_cal._utils import safe_json_loads, filter_events, services as _services
+from room_cal.pricing.price_calendar import PriceData
+
+if TYPE_CHECKING:
+  from room_cal.service_api import ServiceAPI
+
+ACTION_SPEC = ('push-prices', [])
+
+_PRICE_PREFIX = 'Price: $'
+
+
+async def push_prices(
+  start_date: date,
+  end_date: date,
+  *,
+  api: 'ServiceAPI | None' = None,
+) -> list[dict]:
+  """Read local CalDAV price events and push their list prices to the service API.
+
+  Only dates that have a local price event are pushed — dates without one are
+  skipped because the local calendar is the source of truth for pricing.
+  Skips booked dates (handled by set_prices internally).
+  Returns list of {date, prices} for each date pushed, where prices is
+  {service: list_price}.
+  """
+  from room_cal.service_api import ServiceAPI as _ServiceAPI
+  if api is None:
+    api = _ServiceAPI()
+
+  cal = Calendar()
+  events = await cal.fetch_date_range(start_date, end_date)
+
+  svc_keys = [s.key for s in _services()]
+  cal_prices: dict[str, PriceData] = {}
+  for e in filter_events(events, _PRICE_PREFIX, all_day=True):
+    d = safe_json_loads(e['x_data'])
+    if d is not None:
+      cal_prices[e['start'].date().isoformat()] = PriceData.from_dict(d, svc_keys)
+
+  results: list[dict] = []
+  for ds in sorted(cal_prices):
+    pd = cal_prices[ds]
+    to_push = {k: v for k, v in pd.service_prices.items() if v is not None}
+    if not to_push:
+      continue
+    await api.set_prices(date.fromisoformat(ds), to_push)
+    results.append({'date': date.fromisoformat(ds), 'prices': to_push})
+
+  return results

+ 71 - 0
room_cal/api/__init__.py

@@ -0,0 +1,71 @@
+import asyncio
+import contextlib
+from contextlib import asynccontextmanager
+from pathlib import Path
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+from starlette.middleware.base import BaseHTTPMiddleware
+from room_cal.api._routes import services, calc, chat, credentials, prices, reservations, closed, discounts, actions, workflows, health, reload, sms
+import os
+from room_cal.api._auth import router as auth_router, get_user, AUTH_DISABLED
+from room_cal._health import run_health_loop
+
+_VUE = Path(__file__).parents[1] / 'vue'
+_DEV_HTML = _VUE / 'dev.html'
+_CALENDAR_HTML = _VUE / 'calendar.html'
+_CALC_HTML = _VUE / 'calc.html'
+_API_BASE = os.getenv('APP_BASE_URL', '').rstrip('/')
+
+def _render(path: Path) -> str:
+  return path.read_text().replace("const API_BASE = '';", f"const API_BASE = '{_API_BASE}';", 1)
+
+_HTML_PATHS = {'/dev', '/calendar', '/calc'}
+_PUBLIC_PREFIXES = ('/auth/', '/calc', '/health', '/sms/')
+_OPEN_PATHS = {'/docs', '/openapi.json', '/redoc'}
+
+
+class _AuthMiddleware(BaseHTTPMiddleware):
+  async def dispatch(self, request: Request, call_next):
+    path = request.url.path
+    if path in _OPEN_PATHS or any(path.startswith(p) for p in _PUBLIC_PREFIXES):
+      return await call_next(request)
+    user = get_user(request)
+    if not user:
+      if path in _HTML_PATHS:
+        return RedirectResponse(f'/auth/login?next={path}')
+      return JSONResponse({'detail': 'Not authenticated'}, status_code=401)
+    request.state.user = user
+    return await call_next(request)
+
+
+@asynccontextmanager
+async def _lifespan(app: FastAPI):
+  task = asyncio.create_task(run_health_loop())
+  yield
+  task.cancel()
+  with contextlib.suppress(asyncio.CancelledError):
+    await task
+
+
+def create_app() -> FastAPI:
+  app = FastAPI(title='room_cal', lifespan=_lifespan)
+  app.add_middleware(CORSMiddleware, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])
+  app.add_middleware(_AuthMiddleware)
+  app.include_router(auth_router)
+  for mod in (services, calc, chat, credentials, prices, reservations, closed, discounts, actions, workflows, health, reload, sms):
+    app.include_router(mod.router)
+
+  @app.get('/dev', response_class=HTMLResponse, include_in_schema=False)
+  async def dev_ui():
+    return _render(_DEV_HTML)
+
+  @app.get('/calendar', response_class=HTMLResponse, include_in_schema=False)
+  async def calendar_ui():
+    return _render(_CALENDAR_HTML)
+
+  @app.get('/calc', response_class=HTMLResponse, include_in_schema=False)
+  async def calc_ui():
+    return _render(_CALC_HTML)
+
+  return app

+ 128 - 0
room_cal/api/_auth.py

@@ -0,0 +1,128 @@
+import os
+import secrets
+import hashlib
+import base64
+import time
+from typing import Optional
+from urllib.parse import urlencode
+
+import httpx
+import jwt
+from fastapi import APIRouter, Request
+from fastapi.responses import JSONResponse, RedirectResponse
+
+AUTH_DISABLED = os.getenv('AUTH_DISABLED', '').lower() in ('1', 'true', 'yes')
+OIDC_ISSUER = os.getenv('OIDC_ISSUER', '')
+OIDC_CLIENT_ID = os.getenv('OIDC_CLIENT_ID', '')
+OIDC_CLIENT_SECRET = os.getenv('OIDC_CLIENT_SECRET', '')
+APP_BASE_URL = os.getenv('APP_BASE_URL', 'http://localhost:8000')
+SESSION_SECRET = os.getenv('SESSION_SECRET') or secrets.token_hex(32)
+
+_SECURE = APP_BASE_URL.startswith('https://')
+_SESSION_COOKIE = 'room_session'
+_STATE_COOKIE = 'room_oauth_state'
+
+_oidc_config: Optional[dict] = None
+_jwks_client: Optional[jwt.PyJWKClient] = None
+
+
+async def _oidc() -> dict:
+  global _oidc_config
+  if _oidc_config is None:
+    async with httpx.AsyncClient() as c:
+      r = await c.get(f'{OIDC_ISSUER}/.well-known/openid-configuration')
+      r.raise_for_status()
+      _oidc_config = r.json()
+  return _oidc_config
+
+
+def _jwks(uri: str) -> jwt.PyJWKClient:
+  global _jwks_client
+  if _jwks_client is None:
+    _jwks_client = jwt.PyJWKClient(uri)
+  return _jwks_client
+
+
+def _sign(data: dict, ttl: int = 86400) -> str:
+  return jwt.encode({**data, 'exp': int(time.time()) + ttl}, SESSION_SECRET, algorithm='HS256')
+
+
+def _verify(token: str) -> Optional[dict]:
+  try:
+    return jwt.decode(token, SESSION_SECRET, algorithms=['HS256'])
+  except Exception:
+    return None
+
+
+def get_user(request: Request) -> Optional[dict]:
+  if AUTH_DISABLED:
+    return {'sub': 'dev', 'email': 'dev@localhost', 'name': 'Dev User'}
+  token = request.cookies.get(_SESSION_COOKIE)
+  return _verify(token) if token else None
+
+
+router = APIRouter(prefix='/auth', tags=['auth'])
+
+
+@router.get('/login')
+async def login(request: Request, next: str = '/calendar'):
+  if AUTH_DISABLED:
+    return RedirectResponse(next)
+  config = await _oidc()
+  verifier = secrets.token_urlsafe(64)
+  challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=').decode()
+  state = secrets.token_urlsafe(16)
+  state_token = _sign({'state': state, 'verifier': verifier, 'next': next}, ttl=600)
+  url = config['authorization_endpoint'] + '?' + urlencode({
+    'response_type': 'code',
+    'client_id': OIDC_CLIENT_ID,
+    'redirect_uri': f'{APP_BASE_URL}/auth/callback',
+    'scope': 'openid email profile',
+    'state': state,
+    'code_challenge': challenge,
+    'code_challenge_method': 'S256',
+  })
+  resp = RedirectResponse(url)
+  resp.set_cookie(_STATE_COOKIE, state_token, httponly=True, secure=_SECURE, samesite='lax', max_age=600)
+  return resp
+
+
+@router.get('/callback')
+async def callback(request: Request, code: str, state: str):
+  state_data = _verify(request.cookies.get(_STATE_COOKIE, ''))
+  if not state_data or state_data.get('state') != state:
+    return RedirectResponse('/auth/login')
+  config = await _oidc()
+  async with httpx.AsyncClient() as c:
+    r = await c.post(config['token_endpoint'], data={
+      'grant_type': 'authorization_code',
+      'code': code,
+      'redirect_uri': f'{APP_BASE_URL}/auth/callback',
+      'client_id': OIDC_CLIENT_ID,
+      'client_secret': OIDC_CLIENT_SECRET,
+      'code_verifier': state_data['verifier'],
+    })
+    r.raise_for_status()
+    tokens = r.json()
+  signing_key = _jwks(config['jwks_uri']).get_signing_key_from_jwt(tokens['id_token'])
+  claims = jwt.decode(tokens['id_token'], signing_key.key, algorithms=['RS256'], audience=OIDC_CLIENT_ID)
+  session = _sign({'sub': claims['sub'], 'email': claims.get('email', ''), 'name': claims.get('name', '')})
+  resp = RedirectResponse(state_data.get('next', '/calendar'), status_code=302)
+  resp.set_cookie(_SESSION_COOKIE, session, httponly=True, secure=_SECURE, samesite='lax', max_age=86400)
+  resp.delete_cookie(_STATE_COOKIE)
+  return resp
+
+
+@router.get('/logout')
+async def logout():
+  resp = RedirectResponse('/auth/login')
+  resp.delete_cookie(_SESSION_COOKIE)
+  return resp
+
+
+@router.get('/me')
+async def me(request: Request):
+  user = get_user(request)
+  if not user:
+    return JSONResponse({'detail': 'Not authenticated'}, status_code=401)
+  return {'sub': user['sub'], 'email': user['email'], 'name': user['name']}

+ 12 - 0
room_cal/api/_routes/__init__.py

@@ -0,0 +1,12 @@
+from datetime import date, datetime
+from room_cal._api_source import API_SOURCE  # noqa: F401  re-exported for route modules
+
+
+def _serialize(obj):
+  if isinstance(obj, (date, datetime)):
+    return obj.isoformat()
+  if isinstance(obj, dict):
+    return {k: _serialize(v) for k, v in obj.items()}
+  if isinstance(obj, list):
+    return [_serialize(v) for v in obj]
+  return obj

+ 42 - 0
room_cal/api/_routes/actions.py

@@ -0,0 +1,42 @@
+import importlib
+import logging
+import pkgutil
+from datetime import date
+from fastapi import APIRouter, HTTPException, Body
+from room_cal.api._routes import _serialize
+import room_cal.actions as _actions_pkg
+
+_log = logging.getLogger(__name__)
+
+router = APIRouter()
+
+_ACTIONS: dict[str, tuple[str, str, list[str]]] = {}
+for _info in pkgutil.iter_modules(_actions_pkg.__path__):
+  if _info.name.startswith('_'):
+    continue
+  _mod = importlib.import_module(f'room_cal.actions.{_info.name}')
+  _spec = getattr(_mod, 'ACTION_SPEC', None)
+  if _spec:
+    _route_name, _allowed_keys = _spec
+    _ACTIONS[_route_name] = (f'room_cal.actions.{_info.name}', _info.name, _allowed_keys)
+
+
+async def _run(action: str, start_date: date, end_date: date, body: dict) -> object:
+  if action not in _ACTIONS:
+    raise HTTPException(404, f'Unknown action: {action!r}')
+  module_path, fn_name, allowed_keys = _ACTIONS[action]
+  mod = importlib.import_module(module_path)
+  fn = getattr(mod, fn_name)
+  kwargs = {k: v for k, v in body.items() if k in allowed_keys}
+  _log.info('action %s %s..%s', action, start_date, end_date)
+  return _serialize(await fn(start_date, end_date, **kwargs))
+
+
+@router.post('/action/{action}/{start_date}')
+async def run_action(action: str, start_date: date, body: dict = Body(default={})):
+  return await _run(action, start_date, start_date, body)
+
+
+@router.post('/action/{action}/{start_date}/{end_date}')
+async def run_action_range(action: str, start_date: date, end_date: date, body: dict = Body(default={})):
+  return await _run(action, start_date, end_date, body)

+ 35 - 0
room_cal/api/_routes/calc.py

@@ -0,0 +1,35 @@
+from fastapi import APIRouter, HTTPException, Query
+from room_cal.pricing.calc import (
+  net_to_list, list_to_net, list_to_retail,
+  retail_to_list, net_to_retail, retail_to_net,
+  calc_details,
+)
+
+router = APIRouter()
+
+_VALID = {
+  'net_to_list', 'list_to_net', 'list_to_retail',
+  'retail_to_list', 'net_to_retail', 'retail_to_net',
+}
+_FROM = {
+  'net_to_list': 'net',   'net_to_retail': 'net',
+  'list_to_net': 'list',  'list_to_retail': 'list',
+  'retail_to_list': 'retail', 'retail_to_net': 'retail',
+}
+
+
+@router.get('/calc/{transformation}')
+async def calc(transformation: str, service: str = Query(...), amount: int = Query(...)):
+  if transformation not in _VALID:
+    raise HTTPException(404, f'Unknown transformation: {transformation!r}')
+  try:
+    from_type = _FROM[transformation]
+    if from_type == 'net':
+      list_ = net_to_list(service, amount)
+      return {'net': amount, 'list': list_, 'retail': net_to_retail(service, amount), 'details': calc_details(service, list_)}
+    if from_type == 'list':
+      return {'net': list_to_net(service, amount), 'list': amount, 'retail': list_to_retail(service, amount), 'details': calc_details(service, amount)}
+    list_ = retail_to_list(service, amount)
+    return {'net': retail_to_net(service, amount), 'list': list_, 'retail': amount, 'details': calc_details(service, list_)}
+  except ValueError as e:
+    raise HTTPException(400, str(e))

+ 70 - 0
room_cal/api/_routes/chat.py

@@ -0,0 +1,70 @@
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+from room_cal.service_api import ServiceAPI
+from room_cal.service_api.types import ServiceAuthError
+
+router = APIRouter()
+
+
+def _normalize_message(m) -> dict:
+  result = {
+    'id': m.id,
+    'created_at': getattr(m, 'created_at_ms', None) or getattr(m, 'created_at', None),
+    'body': m.body,
+    'sender': getattr(m, 'sender_account_id', None) or getattr(m, 'sender', None),
+  }
+  translated = getattr(m, 'body_translated', None)
+  if translated is not None:
+    result['body_translated'] = translated
+  return result
+
+
+def _normalize_thread(thread) -> dict:
+  chat_id = getattr(thread, 'thread_id', None) or getattr(thread, 'reservation_id', None)
+  result: dict = {
+    'chat_id': chat_id,
+    'messages': [_normalize_message(m) for m in thread.messages],
+  }
+  if hasattr(thread, 'is_reply_needed'):
+    result['is_reply_needed'] = thread.is_reply_needed
+  return result
+
+
+def _get_chat_client(service_key: str):
+  api = ServiceAPI()
+  try:
+    return api.chat_client(service_key)
+  except KeyError:
+    raise HTTPException(400, f'unknown service: {service_key!r}')
+  except TypeError:
+    raise HTTPException(400, f'{service_key!r} does not support chat')
+
+
+class _SendBody(BaseModel):
+  body: str
+
+
+@router.get('/chat/{service_key}/{chat_id}')
+async def get_thread(service_key: str, chat_id: str):
+  client = _get_chat_client(service_key)
+  try:
+    thread = await client.get_thread(chat_id)
+  except ServiceAuthError as e:
+    raise HTTPException(503, str(e))
+  except Exception as e:
+    raise HTTPException(502, str(e))
+  return _normalize_thread(thread)
+
+
+@router.post('/chat/{service_key}/{chat_id}', status_code=204)
+async def send_message(service_key: str, chat_id: str, payload: _SendBody):
+  if service_key == 'booking':
+    raise HTTPException(501, 'Booking.com send_message is temporarily disabled pending API debugging')
+  client = _get_chat_client(service_key)
+  try:
+    await client.send_message(chat_id, payload.body)
+  except ServiceAuthError as e:
+    raise HTTPException(503, str(e))
+  except Exception as e:
+    raise HTTPException(502, str(e))

+ 37 - 0
room_cal/api/_routes/closed.py

@@ -0,0 +1,37 @@
+from datetime import date
+from fastapi import APIRouter, Body
+from room_cal.closed import Closed, ClosedData
+from room_cal.cal import DEFAULT_CALENDAR_NAME
+from room_cal._utils import safe_json_loads, iter_dates
+from room_cal.api._routes import API_SOURCE
+
+router = APIRouter()
+_SOURCE = API_SOURCE
+
+
+@router.get('/closed/{target_date}')
+async def get_closed(target_date: date):
+  data = await Closed(DEFAULT_CALENDAR_NAME).get(target_date)
+  return data.services if data else []
+
+
+@router.get('/closed/{start_date}/{end_date}')
+async def get_closed_range(start_date: date, end_date: date):
+  events = await Closed(DEFAULT_CALENDAR_NAME).list(start_date, end_date)
+  by_date: dict[str, list] = {str(d): [] for d in iter_dates(start_date, end_date)}
+  for ev in events:
+    d = ev['start'].date()
+    if start_date <= d <= end_date:
+      xd = safe_json_loads(ev['x_data'])
+      by_date[str(d)] = (xd or {}).get('closed_services', [])
+  return by_date
+
+
+@router.post('/closed/{target_date}', status_code=204)
+async def set_closed(target_date: date, body: dict = Body(...)):
+  await Closed(DEFAULT_CALENDAR_NAME).set(target_date, ClosedData(services=body.get('services', []), source=_SOURCE))
+
+
+@router.delete('/closed/{target_date}', status_code=204)
+async def delete_closed(target_date: date):
+  await Closed(DEFAULT_CALENDAR_NAME).set(target_date, ClosedData(services=[]))

+ 42 - 0
room_cal/api/_routes/credentials.py

@@ -0,0 +1,42 @@
+import asyncio
+from fastapi import APIRouter, Body
+from room_cal.service_api import ServiceAPI
+from room_cal.service_api.credentials import set_credentials, check_credentials
+from room_cal.service_api.types import ServiceAuthError
+from room_cal._utils import services, local_today
+from room_cal._health import probe_once
+
+router = APIRouter()
+
+
+async def _probe(key: str, api: ServiceAPI) -> dict:
+  today = local_today().isoformat()
+  try:
+    await api._clients[key].get_calendar(today, today)
+    return {'ok': True}
+  except ServiceAuthError as e:
+    return {'ok': False, 'reason': str(e)}
+  except Exception as e:
+    return {'ok': False, 'reason': f'probe error: {e}'}
+
+
+@router.get('/credentials')
+async def get_credentials_status():
+  api = ServiceAPI()
+  keys = [s.key for s in services() if s.key in api._clients]
+  results = await asyncio.gather(*[_probe(k, api) for k in keys])
+  return dict(zip(keys, results))
+
+
+@router.get('/credentials/{service}')
+async def get_credential_status(service: str):
+  api = ServiceAPI()
+  if service not in api._clients:
+    return check_credentials(service)
+  return await _probe(service, api)
+
+
+@router.put('/credentials/{service}', status_code=204)
+async def put_credentials(service: str, credentials: dict[str, str] = Body(...)):
+  set_credentials(service, credentials)
+  asyncio.create_task(probe_once())

+ 44 - 0
room_cal/api/_routes/discounts.py

@@ -0,0 +1,44 @@
+from datetime import date
+from fastapi import APIRouter, HTTPException, Body
+from room_cal.discount import Discount, DiscountData, DiscountType
+from room_cal.cal import DEFAULT_CALENDAR_NAME
+from room_cal._utils import safe_json_loads, iter_dates
+from room_cal.api._routes import API_SOURCE
+
+router = APIRouter()
+_SOURCE = API_SOURCE
+
+
+@router.get('/discount/{target_date}')
+async def get_discount(target_date: date):
+  data = await Discount(DEFAULT_CALENDAR_NAME).get(target_date)
+  if data is None:
+    return None
+  return {'type': data.type, 'value': data.value}
+
+
+@router.get('/discount/{start_date}/{end_date}')
+async def get_discount_range(start_date: date, end_date: date):
+  events = await Discount(DEFAULT_CALENDAR_NAME).list(start_date, end_date)
+  by_date: dict[str, dict | None] = {str(d): None for d in iter_dates(start_date, end_date)}
+  for ev in events:
+    d = ev['start'].date()
+    if start_date <= d <= end_date:
+      xd = safe_json_loads(ev['x_data'])
+      if xd:
+        by_date[str(d)] = {'type': xd.get('type'), 'value': xd.get('value')}
+  return by_date
+
+
+@router.post('/discount/{target_date}', status_code=204)
+async def set_discount(target_date: date, body: dict = Body(...)):
+  discount_type = body.get('type')
+  value = body.get('value')
+  if discount_type not in ('percent', 'flat') or value is None:
+    raise HTTPException(422, 'body must include "type" ("percent" or "flat") and "value"')
+  await Discount(DEFAULT_CALENDAR_NAME).set(target_date, DiscountData(type=DiscountType(discount_type), value=round(value), source=_SOURCE))
+
+
+@router.delete('/discount/{target_date}', status_code=204)
+async def delete_discount(target_date: date):
+  await Discount(DEFAULT_CALENDAR_NAME).delete_on(target_date)

+ 35 - 0
room_cal/api/_routes/health.py

@@ -0,0 +1,35 @@
+from fastapi import APIRouter
+from fastapi.responses import JSONResponse
+
+from room_cal._health import get_cached
+
+router = APIRouter()
+
+
+@router.get('/health', include_in_schema=False)
+async def healthcheck():
+  result, _ = get_cached()
+  if result is None:
+    return JSONResponse({'status': 'starting'}, status_code=503)
+  checks = result.get('checks', {})
+  caldav_ok = (
+    checks.get('caldav_connection', {}).get('ok', False)
+    and checks.get('calendar', {}).get('ok', False)
+  )
+  if caldav_ok:
+    return JSONResponse({'status': 'ok'})
+  return JSONResponse(
+    {'status': 'error', 'checks': {k: checks[k] for k in ('caldav_connection', 'calendar') if k in checks}},
+    status_code=503,
+  )
+
+
+@router.get('/status', include_in_schema=False)
+async def status():
+  result, stale = get_cached()
+  if result is None:
+    return JSONResponse({'status': 'starting', 'checks': {}}, status_code=503)
+  if stale:
+    return JSONResponse(result, status_code=503)
+  status_code = 200 if result['status'] == 'ok' else 503
+  return JSONResponse(result, status_code=status_code)

+ 20 - 0
room_cal/api/_routes/prices.py

@@ -0,0 +1,20 @@
+from datetime import date
+from fastapi import APIRouter, Body, HTTPException
+from room_cal.pricing import CalPrice
+from room_cal.cal import DEFAULT_CALENDAR_NAME
+
+router = APIRouter()
+
+
+@router.get('/price/{start_date}/{end_date}')
+async def get_price_range(start_date: date, end_date: date):
+  price_range = await CalPrice(DEFAULT_CALENDAR_NAME).get(start_date, end_date)
+  return {k: v.to_dict() if v else None for k, v in price_range.items()}
+
+
+@router.post('/price/{target_date}', status_code=204)
+async def set_price(target_date: date, body: dict = Body(...)):
+  value = body.get('value')
+  if value is None:
+    raise HTTPException(422, 'body must include "value"')
+  await CalPrice(DEFAULT_CALENDAR_NAME).set(target_date, float(value))

+ 15 - 0
room_cal/api/_routes/reload.py

@@ -0,0 +1,15 @@
+from fastapi import APIRouter
+from room_cal.cal import clear_caches
+
+router = APIRouter()
+
+
+@router.post('/reload', tags=['system'])
+async def reload():
+  """Clear all CalDAV event caches so the next request re-fetches from the server.
+
+  Config is re-read from disk on every call already; this only flushes the
+  in-memory event cache maintained by Calendar instances.
+  """
+  count = clear_caches()
+  return {'cleared_calendars': count}

+ 37 - 0
room_cal/api/_routes/reservations.py

@@ -0,0 +1,37 @@
+from datetime import date
+from fastapi import APIRouter
+from room_cal.reservations import Reservation
+from room_cal.cal import DEFAULT_CALENDAR_NAME
+from room_cal._utils import safe_json_loads, iter_dates
+
+router = APIRouter()
+
+
+def _enrich(ev: dict) -> dict:
+  xd = safe_json_loads(ev['x_data']) or {}
+  xd['checkin_date'] = ev['start'].date().isoformat()
+  xd['checkout_date'] = ev['end'].date().isoformat()
+  return xd
+
+
+@router.get('/reservation/{target_date}')
+async def get_reservation(target_date: date):
+  events = await Reservation(DEFAULT_CALENDAR_NAME).list(target_date, target_date)
+  for ev in events:
+    if ev['start'].date() <= target_date < ev['end'].date():
+      return _enrich(ev)
+  return None
+
+
+@router.get('/reservation/{start_date}/{end_date}')
+async def get_reservation_range(start_date: date, end_date: date):
+  events = await Reservation(DEFAULT_CALENDAR_NAME).list(start_date, end_date)
+  result: dict[str, dict | None] = {str(d): None for d in iter_dates(start_date, end_date)}
+  for ev in events:
+    checkin = ev['start'].date()
+    checkout = ev['end'].date()
+    enriched = _enrich(ev)
+    for d in iter_dates(start_date, end_date):
+      if checkin <= d < checkout:
+        result[str(d)] = enriched
+  return result

+ 23 - 0
room_cal/api/_routes/services.py

@@ -0,0 +1,23 @@
+from fastapi import APIRouter
+from room_cal._utils import services as _services
+from room_cal.service_api._airbnb import _AirbnbClient
+from room_cal.service_api._booking import _BookingClient
+router = APIRouter()
+
+_CLIENT_CLASSES: dict[str, type] = {
+  'airbnb': _AirbnbClient,
+  'booking': _BookingClient,
+}
+
+
+@router.get('/services')
+async def get_services():
+  return [
+    {
+      'key': s.key,
+      'name': s.name,
+      'supports_chat': hasattr(_CLIENT_CLASSES.get(s.key), 'get_thread'),
+      'host_account_id': s.host_account_id,
+    }
+    for s in _services()
+  ]

+ 53 - 0
room_cal/api/_routes/sms.py

@@ -0,0 +1,53 @@
+import asyncio
+import os
+from fastapi import APIRouter, Body, Header, HTTPException
+from room_cal.api._auth import AUTH_DISABLED
+
+router = APIRouter()
+
+_SMS_SECRET = os.getenv('SMS_GATEWAY_SECRET', '')
+_pending: 'asyncio.Queue[str] | None' = None
+
+
+def _queue() -> 'asyncio.Queue[str]':
+  global _pending
+  if _pending is None:
+    _pending = asyncio.Queue(maxsize=1)
+  return _pending
+
+
+def _check_secret(secret: str | None) -> None:
+  if AUTH_DISABLED:
+    return
+  if _SMS_SECRET and secret != _SMS_SECRET:
+    raise HTTPException(status_code=403, detail='Invalid secret')
+
+
+@router.post('/sms/code', status_code=204)
+async def receive_code(
+  payload: dict = Body(...),
+  x_sms_secret: str | None = Header(None),
+):
+  """Receive an SMS challenge code from the SMS gateway."""
+  _check_secret(x_sms_secret)
+  code = payload.get('code')
+  if not code:
+    raise HTTPException(status_code=422, detail='Missing code field')
+  q = _queue()
+  if q.full():
+    q.get_nowait()  # discard stale unread code
+  q.put_nowait(str(code))
+
+
+@router.get('/sms/code')
+async def poll_code(
+  timeout: int = 60,
+  x_sms_secret: str | None = Header(None),
+):
+  """Long-poll for an SMS challenge code. Returns and discards it once delivered."""
+  _check_secret(x_sms_secret)
+  try:
+    code = await asyncio.wait_for(_queue().get(), timeout=timeout)
+    return {'code': code}
+  except asyncio.TimeoutError:
+    raise HTTPException(status_code=404, detail='No code received within timeout')

+ 12 - 0
room_cal/api/_routes/workflows.py

@@ -0,0 +1,12 @@
+import importlib
+import pkgutil
+from fastapi import APIRouter
+import room_cal.workflows as _workflows_pkg
+
+router = APIRouter()
+for _info in pkgutil.iter_modules(_workflows_pkg.__path__):
+  if _info.name.startswith('_'):
+    continue
+  _mod = importlib.import_module(f'room_cal.workflows.{_info.name}')
+  if hasattr(_mod, 'router'):
+    router.include_router(_mod.router)

+ 6 - 0
room_cal/api/_server.py

@@ -0,0 +1,6 @@
+import uvicorn
+from room_cal.api import create_app
+
+
+def main() -> None:
+  uvicorn.run(create_app(), host='127.0.0.1', port=8000)

+ 36 - 0
room_cal/busy/__init__.py

@@ -0,0 +1,36 @@
+from datetime import date
+from room_cal.cal import CalendarBacked
+from room_cal._utils import get_config
+
+DEFAULT_CALENDAR_NAME = 'Busy'
+_BUSY_TITLE = 'Busy'
+_UNSET = object()
+
+
+def _get_calendar_name() -> str:
+  return get_config().global_.busy_calendar_name or DEFAULT_CALENDAR_NAME
+
+
+class BusyCal(CalendarBacked):
+  def __init__(self, calendar_name: str | None = None, *, cal=None):
+    name = calendar_name or _get_calendar_name()
+    super().__init__(name, cal=cal)
+
+  async def list(self, start_date: date, end_date: date) -> list[dict]:
+    events = await self._cal.fetch_date_range(start_date, end_date)
+    return [e for e in events if e['all_day'] and e['title'] == _BUSY_TITLE]
+
+  async def set(self, target_date: date, *, existing=_UNSET) -> None:
+    if existing is _UNSET:
+      existing = await self._find(target_date)
+    if existing:
+      return
+    await self._cal.create_event(target_date, _BUSY_TITLE)
+
+  async def delete(self, event: dict) -> None:
+    await self._cal.delete_event(event)
+
+  async def _find(self, target_date: date) -> dict | None:
+    events = await self._cal.fetch_date_range(target_date, target_date)
+    matches = [e for e in events if e['all_day'] and e['title'] == _BUSY_TITLE]
+    return matches[0] if matches else None

+ 235 - 0
room_cal/cal/__init__.py

@@ -0,0 +1,235 @@
+import asyncio
+import logging
+import os
+import uuid
+from caldav import aio
+from datetime import date, datetime, timedelta, timezone
+from icalendar import Calendar as _iCal, Event as _iEvent
+
+from room_cal._utils import data_home, read_env
+
+_log = logging.getLogger(__name__)
+
+
+def _caldav_config() -> dict[str, str]:
+  env_file = data_home() / 'caldav.env'
+  env = {**read_env(env_file), **os.environ}
+  url = env.get('CALDAV_URL', '')
+  username = env.get('CALDAV_USERNAME', '')
+  password = env.get('CALDAV_PASSWORD', '')
+  if not url:
+    raise ValueError(f'CALDAV_URL must be set in {env_file}')
+  return {'url': url, 'username': username, 'password': password}
+
+
+def _naive(dt) -> datetime:
+  if isinstance(dt, datetime):
+    return dt.replace(tzinfo=None)
+  return datetime(dt.year, dt.month, dt.day)
+
+
+_X_DATA_PROP = 'X-ROOMCAL-DATA'
+
+
+def _parse_event(caldav_event) -> dict:
+  cal = caldav_event.icalendar_instance
+  vevent = next(c for c in cal.walk() if c.name == 'VEVENT')
+
+  dtstart = vevent.get('DTSTART').dt
+  dtend_prop = vevent.get('DTEND') or vevent.get('DUE')
+  dtend = dtend_prop.dt if dtend_prop else dtstart
+
+  all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime)
+
+  recurrence_id = vevent.get('RECURRENCE-ID')
+
+  x_data_prop = vevent.get(_X_DATA_PROP)
+  x_data = str(x_data_prop) if x_data_prop is not None else None
+
+  return {
+    'start':        _naive(dtstart),
+    'end':          _naive(dtend),
+    'all_day':      all_day,
+    'title':        str(vevent.get('SUMMARY', '')),
+    'description':  str(vevent.get('DESCRIPTION', '')),
+    'x_data':       x_data,
+    # sync fields
+    'uid':          str(vevent.get('UID', '')),
+    'url':          str(caldav_event.url),
+    'etag':         getattr(caldav_event, 'etag', None),
+    'recurrence_id': _naive(recurrence_id.dt) if recurrence_id else None,
+    # escape hatches
+    '_vevent':      vevent,
+    '_raw_event':   caldav_event,
+  }
+
+
+def _build_ical(dtstart, dtend, title: str, description: str = '', uid: str = None, x_data: str = None) -> str:
+  """Build an iCal string for one event. Pass date objects for all-day, datetime for timed."""
+  cal = _iCal()
+  cal.add('prodid', '-//room_cal//EN')
+  cal.add('version', '2.0')
+
+  event = _iEvent()
+  event.add('uid', uid or str(uuid.uuid4()))
+  event.add('dtstamp', datetime.now(timezone.utc))
+  event.add('dtstart', dtstart)
+  event.add('dtend', dtend)
+  event.add('summary', title)
+  if description:
+    event.add('description', description)
+  if x_data is not None:
+    event.add(_X_DATA_PROP, x_data)
+
+  cal.add_component(event)
+  return cal.to_ical().decode('utf-8')
+
+
+DEFAULT_CALENDAR_NAME = 'Room'
+
+
+class Calendar:
+  def __init__(self, calendar_name=DEFAULT_CALENDAR_NAME):
+    self._calendar_name = calendar_name
+    self._cached_url = None
+    self._event_cache: dict[tuple, list] = {}
+    self._inflight: dict[tuple, asyncio.Task] = {}
+
+  @property
+  def calendar_name(self) -> str:
+    return self._calendar_name
+
+  async def _client(self):
+    return await aio.get_async_davclient(**_caldav_config())
+
+  async def _get_calendar(self, client):
+    if self._cached_url is not None:
+      return client.calendar(url=self._cached_url)
+    principal = await client.principal()
+    calendars = await principal.get_calendars()
+    for cal in calendars:
+      if await cal.get_display_name() == self._calendar_name:
+        self._cached_url = str(cal.url)
+        return cal
+    raise ValueError(f"Calendar '{self._calendar_name}' not found")
+
+  async def fetch_events(self, start: datetime, end: datetime) -> list[dict]:
+    async with await self._client() as client:
+      cal = await self._get_calendar(client)
+      raw_events = await cal.search(
+        start=start,
+        end=end,
+        event=True,
+        expand=True,
+      )
+    events = [_parse_event(e) for e in raw_events]
+    events.sort(key=lambda e: e['start'])
+    return events
+
+  async def fetch_date_range(self, start_date: date, end_date: date) -> list[dict]:
+    """Fetch events covering start_date through end_date (inclusive).
+
+    Results are cached; concurrent calls for the same range share one network request.
+    """
+    key = (start_date, end_date)
+    if key in self._event_cache:
+      return self._event_cache[key]
+    if key in self._inflight:
+      return await self._inflight[key]
+    task = asyncio.create_task(self._fetch_date_range_uncached(start_date, end_date))
+    self._inflight[key] = task
+    try:
+      result = await task
+    finally:
+      self._inflight.pop(key, None)
+    self._event_cache[key] = result
+    return result
+
+  async def _fetch_date_range_uncached(self, start_date: date, end_date: date) -> list[dict]:
+    start = datetime(start_date.year, start_date.month, start_date.day)
+    end = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
+    return await self.fetch_events(start, end)
+
+  async def create_timed_event(self, dtstart: datetime, dtend: datetime, title: str, description: str = '', x_data: str = None) -> dict:
+    ical_str = _build_ical(dtstart, dtend, title, description, x_data=x_data)
+    async with await self._client() as client:
+      cal = await self._get_calendar(client)
+      saved = await cal.save_event(ical_str)
+    self._event_cache.clear()
+    _log.info('create date=%s title=%r', dtstart.date(), title)
+    return _parse_event(saved)
+
+  async def create_event(self, target_date: date, title: str, description: str = '', x_data: str = None, end_date: date = None) -> dict:
+    if isinstance(target_date, datetime):
+      target_date = target_date.date()
+    dtend = end_date if end_date is not None else target_date + timedelta(days=1)
+    ical_str = _build_ical(target_date, dtend, title, description, x_data=x_data)
+    async with await self._client() as client:
+      cal = await self._get_calendar(client)
+      saved = await cal.save_event(ical_str)
+    self._event_cache.clear()
+    _log.info('create date=%s title=%r', target_date, title)
+    return _parse_event(saved)
+
+  async def delete_event(self, event_dict: dict) -> None:
+    async with await self._client() as client:
+      cal = await self._get_calendar(client)
+      raw = await cal.event_by_uid(event_dict['uid'])
+      await raw.delete()
+    self._event_cache.clear()
+    _log.info('delete date=%s title=%r', event_dict['start'].date(), event_dict.get('title', ''))
+
+  async def update_event(self, event_dict: dict, title: str = None, description: str = None, x_data: str = None) -> dict:
+    async with await self._client() as client:
+      cal = await self._get_calendar(client)
+      raw = await cal.event_by_uid(event_dict['uid'])
+      ical = raw.icalendar_instance
+      for component in ical.walk():
+        if component.name != 'VEVENT':
+          continue
+        if title is not None:
+          if 'SUMMARY' in component:
+            del component['SUMMARY']
+          component.add('summary', title)
+        if description is not None:
+          if 'DESCRIPTION' in component:
+            del component['DESCRIPTION']
+          component.add('description', description)
+        if x_data is not None:
+          if _X_DATA_PROP in component:
+            del component[_X_DATA_PROP]
+          component.add(_X_DATA_PROP, x_data)
+      raw.data = ical.to_ical().decode('utf-8')
+      await raw.save()
+    self._event_cache.clear()
+    effective_title = title if title is not None else event_dict.get('title', '')
+    _log.info('update date=%s title=%r', event_dict['start'].date(), effective_title)
+    return _parse_event(raw)
+
+
+_registry: dict[str, 'Calendar'] = {}
+
+
+def get_calendar(name: str = DEFAULT_CALENDAR_NAME) -> 'Calendar':
+  """Return the shared Calendar instance for the given name, creating it on first call."""
+  if name not in _registry:
+    _registry[name] = Calendar(name)
+  return _registry[name]
+
+
+def clear_caches() -> int:
+  """Clear event caches and cached URLs for all registered Calendar instances.
+
+  Returns the number of calendars cleared.
+  """
+  for cal in _registry.values():
+    cal._event_cache.clear()
+    cal._cached_url = None
+  return len(_registry)
+
+
+class CalendarBacked:
+  """Base for objects backed by a single named CalDAV calendar."""
+  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, cal: 'Calendar | None' = None):
+    self._calendar_name = calendar_name
+    self._cal = cal if cal is not None else get_calendar(calendar_name)

+ 17 - 0
room_cal/cal/__main__.py

@@ -0,0 +1,17 @@
+import asyncio
+from datetime import datetime, timedelta
+from room_cal.cal import Calendar
+
+
+async def main():
+  cal = Calendar()
+  end = datetime.now()
+  start = end - timedelta(days=30)
+  events = await cal.fetch_events(start, end)
+  print(f"{len(events)} event(s)")
+  for e in events:
+    tag = '[ALL DAY]' if e['all_day'] else ''
+    print(f"  {e['start']} – {e['end']} {tag} {e['title']!r}")
+
+
+asyncio.run(main())

+ 95 - 0
room_cal/closed/__init__.py

@@ -0,0 +1,95 @@
+import json
+from dataclasses import dataclass, field
+from datetime import date
+from room_cal.cal import CalendarBacked, DEFAULT_CALENDAR_NAME
+from room_cal._utils import EventSource, safe_json_loads, filter_events, services as _services, service_name, source_line
+
+_PREFIX = 'Closed: '
+_UNSET = object()
+
+
+@dataclass
+class ClosedData:
+  services: list[str]
+  source: dict = field(default_factory=dict)
+
+
+ClosedList = dict[date, ClosedData]
+
+
+def _build_title(closed_services: list[str]) -> str:
+  all_keys = {s.key for s in _services()}
+  if all_keys <= set(closed_services):
+    return f"{_PREFIX}All"
+  names = [service_name(s) for s in sorted(closed_services)]
+  return f"{_PREFIX}{', '.join(names)}"
+
+
+def _build_description(closed_services: list[str], source: dict) -> str:
+  desc = '\n'.join(f"{service_name(s)}: Closed" for s in sorted(closed_services))
+  if source:
+    desc += f"\nSource: {source_line(source)}"
+  return desc
+
+
+class Closed(CalendarBacked, EventSource):
+  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, cal=None):
+    CalendarBacked.__init__(self, calendar_name, cal=cal)
+    EventSource.__init__(self, 'closed')
+
+  async def set(self, target_date: date, data: 'ClosedData', *, existing=_UNSET) -> None:
+    """Create or update a 'Closed' all-day event. Deletes any existing event if data.services is empty.
+
+    Pass existing=<event> to skip the internal lookup when the caller already holds the event.
+    Pass existing=None to unconditionally create.
+    """
+    if existing is _UNSET:
+      existing = await self._find(target_date)
+    if not data.services:
+      if existing:
+        await self._cal.delete_event(existing)
+      return
+    title = _build_title(data.services)
+    description = _build_description(data.services, data.source)
+    x_data = json.dumps({'closed_services': sorted(data.services), 'source': data.source})
+    if existing:
+      if title != existing.get('title') or description != existing.get('description') or x_data != existing.get('x_data'):
+        await self._cal.update_event(existing, title=title, description=description, x_data=x_data)
+    else:
+      await self._cal.create_event(target_date, title, description, x_data=x_data)
+
+  async def get(self, target_date: date) -> 'ClosedData | None':
+    """Return ClosedData for the date, or None if no closure event exists."""
+    event = await self._find(target_date)
+    if event is None:
+      return None
+    d = safe_json_loads(event['x_data'])
+    if d is None:
+      return None
+    return ClosedData(services=d.get('closed_services', []), source=d.get('source') or {})
+
+  async def list(self, start_date: date, end_date: date) -> list[dict]:
+    """Return all 'Closed' all-day events in the date range."""
+    events = await self._cal.fetch_date_range(start_date, end_date)
+    return filter_events(events, _PREFIX, all_day=True)
+
+  async def get_range(self, start_date: date, end_date: date, *, events: list[dict] | None = None) -> 'ClosedList':
+    """Return {date: ClosedData} for all closed events in range.
+
+    Pass events to reuse a pre-fetched list() result and avoid a second CalDAV call.
+    """
+    result: ClosedList = {}
+    for e in (events if events is not None else await self.list(start_date, end_date)):
+      d = e['start'].date()
+      parsed = safe_json_loads(e['x_data'])
+      if parsed is not None:
+        result[d] = ClosedData(services=parsed.get('closed_services', []), source=parsed.get('source') or {})
+    return result
+
+  async def delete(self, event: dict) -> None:
+    await self._cal.delete_event(event)
+
+  async def _find(self, target_date: date) -> dict | None:
+    events = await self._cal.fetch_date_range(target_date, target_date)
+    matches = filter_events(events, _PREFIX, all_day=True)
+    return matches[0] if matches else None

+ 105 - 0
room_cal/config-all.json

@@ -0,0 +1,105 @@
+{
+  "rules": {
+    "future_block": {
+      "days_out": 150
+    },
+    "short_notice": {
+      "max_days": 14,
+      "ramp_days": 7
+    },
+    "fill_month": {
+      "discount_pct": 10,
+      "max_days": 30,
+      "ramp_days": 7
+    },
+    "fire": {
+      "day_of": 314,
+      "day_before": 111,
+      "day_after": 222,
+      "feed_name": "Fire"
+    },
+    "pipelines": {
+      "this_month": {
+        "type": "discount",
+        "rules": [
+          "short_notice",
+          "fill_month",
+          "no_single_nights"
+        ]
+      },
+      "future_block": {
+        "type": "closed",
+        "rules": [
+          "future_block"
+        ]
+      },
+      "special_events": {
+        "type": "price",
+        "rules": [
+          "fire"
+        ]
+      }
+    }
+  },
+  "global": {
+    "taxes": {
+      "sales": 0.157
+    },
+    "property": {
+      "timezone": "America/Los_Angeles",
+      "checkin_hour": 14,
+      "checkout_hour": 12
+    },
+    "prices": {
+      "weekday": 62,
+      "weekend": 72
+    },
+    "reservation_window_days": 90,
+    "busy_calendar_name": "Busy"
+  },
+  "services": {
+    "airbnb": {
+      "name": "AirBnB",
+      "listing_id": "",
+      "hotel_id": "",
+      "hotel_account_id": "",
+      "host_account_id": "",
+      "room_id": "",
+      "rate_id": "",
+      "fees": {
+        "commission": 0.03,
+        "client_fee": 0.1411235955
+      },
+      "taxes": {
+        "flat_tax": 4
+      }
+    },
+    "booking": {
+      "name": "Booking.com",
+      "listing_id": "",
+      "hotel_id": "",
+      "hotel_account_id": "",
+      "host_account_id": "",
+      "room_id": "",
+      "rate_id": "",
+      "fees": {
+        "commission": 0.15,
+        "client_fee": 0.0
+      },
+      "taxes": {
+        "wrong": 0.145,
+        "flat_tax": 0
+      }
+    }
+  },
+  "calendars": [
+    {
+      "name": "Fire",
+      "url": "webcal://cal.4114.us/fire.ics",
+      "caldav": null,
+      "past_days": null,
+      "future_days": null
+    }
+  ],
+  "api_url": "http://localhost:8000/"
+}

+ 54 - 0
room_cal/config.json

@@ -0,0 +1,54 @@
+{
+  "global": {
+    "taxes": {
+      "sales": 0.157
+    },
+    "prices": {
+      "weekday": 62,
+      "weekend": 72
+    },
+    "reservation_window_days": 90
+  },
+  "services": {
+    "airbnb": {
+      "name": "AirBnB",
+      "host_account_id": "",
+      "taxes": {
+        "flat_tax": 4
+      },
+      "fees": {
+        "commission": 0.03,
+        "client_fee": 0.1411235955
+      }
+    },
+    "booking": {
+      "name": "Booking.com",
+      "taxes": {
+        "wrong": 0.145
+      },
+      "fees": {
+        "commission": 0.15
+      }
+    }
+  },
+  "rules": {
+    "fire": {
+      "day_of": 314,
+      "day_before": 111,
+      "day_after": 222,
+      "feed_name": "Fire"
+    },
+    "pipelines": {
+      "this_month": {"type": "discount", "rules": ["short_notice", "fill_month", "no_single_nights"]},
+      "future_block":  {"type": "closed",   "rules": ["future_block"]},
+      "special_events": {"type": "price",    "rules": ["fire"]}
+    }
+  },
+  "calendars": [
+    {
+      "name": "Fire",
+      "url": "webcal://cal.4114.us/fire.ics"
+    }
+  ],
+  "api_url": "http://localhost:8000/"
+}

+ 99 - 0
room_cal/discount/__init__.py

@@ -0,0 +1,99 @@
+import json
+from dataclasses import dataclass, field
+from datetime import date
+from enum import StrEnum
+from room_cal.cal import CalendarBacked, DEFAULT_CALENDAR_NAME
+from room_cal._utils import safe_json_loads, filter_events, source_line
+
+_PREFIX = 'Discount: '
+_UNSET = object()
+
+
+class DiscountType(StrEnum):
+  FLAT    = 'flat'
+  PERCENT = 'percent'
+
+
+@dataclass
+class DiscountData:
+  type: DiscountType
+  value: int
+  source: dict = field(default_factory=dict)
+
+
+DiscountList = dict[date, DiscountData]
+
+
+def _build_title(discount_type: str, value: int) -> str:
+  if discount_type == DiscountType.FLAT:
+    return f"{_PREFIX}${value}"
+  return f"{_PREFIX}{value}%"
+
+
+def _build_description(discount_type: str, value: int, source: dict) -> str:
+  title_part = f"${value}" if discount_type == DiscountType.FLAT else f"{value}%"
+  desc = f"Discount: {title_part}"
+  if source:
+    desc += f"\nSource: {source_line(source)}"
+  return desc
+
+
+class Discount(CalendarBacked):
+  async def set(self, target_date: date, data: DiscountData, *, existing=_UNSET) -> None:
+    """Create or update a 'Discount' all-day event.
+
+    Pass existing=<event> to skip the internal lookup when the caller already holds the event.
+    Pass existing=None to unconditionally create.
+    """
+    title = _build_title(data.type, data.value)
+    description = _build_description(data.type, data.value, data.source)
+    x_data = json.dumps({'type': data.type, 'value': data.value, 'source': data.source})
+    if existing is _UNSET:
+      existing = await self._find(target_date)
+    if existing:
+      if title != existing.get('title') or description != existing.get('description') or x_data != existing.get('x_data'):
+        await self._cal.update_event(existing, title=title, description=description, x_data=x_data)
+    else:
+      await self._cal.create_event(target_date, title, description, x_data=x_data)
+
+  async def get(self, target_date: date) -> 'DiscountData | None':
+    """Return DiscountData for the date, or None if no discount event exists."""
+    event = await self._find(target_date)
+    if event is None:
+      return None
+    d = safe_json_loads(event['x_data'])
+    if d is None:
+      return None
+    return DiscountData(type=DiscountType(d['type']), value=d['value'], source=d.get('source') or {})
+
+  async def list(self, start_date: date, end_date: date) -> list[dict]:
+    """Return all 'Discount' all-day events in the date range."""
+    events = await self._cal.fetch_date_range(start_date, end_date)
+    return filter_events(events, _PREFIX, all_day=True)
+
+  async def get_range(self, start_date: date, end_date: date, *, events: list[dict] | None = None) -> 'DiscountList':
+    """Return {date: DiscountData} for all discount events in range.
+
+    Pass events to reuse a pre-fetched list() result and avoid a second CalDAV call.
+    """
+    result: DiscountList = {}
+    for e in (events if events is not None else await self.list(start_date, end_date)):
+      d = e['start'].date()
+      parsed = safe_json_loads(e['x_data'])
+      if parsed is not None:
+        result[d] = DiscountData(type=DiscountType(parsed['type']), value=parsed['value'], source=parsed.get('source') or {})
+    return result
+
+  async def delete(self, event: dict) -> None:
+    await self._cal.delete_event(event)
+
+  async def delete_on(self, target_date: date) -> None:
+    """Delete the discount event on the given date, if any."""
+    existing = await self._find(target_date)
+    if existing:
+      await self._cal.delete_event(existing)
+
+  async def _find(self, target_date: date) -> dict | None:
+    events = await self._cal.fetch_date_range(target_date, target_date)
+    matches = filter_events(events, _PREFIX, all_day=True)
+    return matches[0] if matches else None

+ 99 - 0
room_cal/ical_feeds/__init__.py

@@ -0,0 +1,99 @@
+import urllib.request
+from datetime import date, datetime, timedelta
+
+from room_cal._utils import CalendarEntry, EventSource, get_config
+
+_source = EventSource('ical_feeds')
+
+
+_TIMEOUT = 15
+_DEFAULT_PAST_DAYS = 365
+_DEFAULT_FUTURE_DAYS = 730
+
+
+def _webcal_to_https(url: str) -> str:
+  if url.startswith('webcal://'):
+    return 'https://' + url[len('webcal://'):]
+  return url
+
+
+def _parse_dt(dt) -> datetime:
+  if isinstance(dt, datetime):
+    return dt.replace(tzinfo=None)
+  if isinstance(dt, date):
+    return datetime(dt.year, dt.month, dt.day)
+  return dt
+
+
+def _is_all_day(dt) -> bool:
+  return isinstance(dt, date) and not isinstance(dt, datetime)
+
+
+def _parse_ical(raw_bytes: bytes, calendar_name: str) -> list[dict]:
+  from icalendar import Calendar as _iCal
+  cal = _iCal.from_ical(raw_bytes)
+  events = []
+  for component in cal.walk():
+    if component.name != 'VEVENT':
+      continue
+    dtstart_prop = component.get('DTSTART')
+    if dtstart_prop is None:
+      continue
+    dtstart = dtstart_prop.dt
+    dtend_prop = component.get('DTEND') or component.get('DUE')
+    dtend = dtend_prop.dt if dtend_prop else dtstart
+
+    events.append({
+      'calendar':    calendar_name,
+      'start':       _parse_dt(dtstart),
+      'end':         _parse_dt(dtend),
+      'all_day':     _is_all_day(dtstart),
+      'title':       str(component.get('SUMMARY', '')),
+      'description': str(component.get('DESCRIPTION', '')),
+      'uid':         str(component.get('UID', '')),
+    })
+  events.sort(key=lambda e: e['start'])
+  return events
+
+
+async def _fetch_caldav(entry: CalendarEntry) -> list[dict]:
+  from room_cal.cal import Calendar
+
+  calendar_name = entry.caldav
+  today = datetime.today()
+  start = today - timedelta(days=entry.past_days if entry.past_days is not None else _DEFAULT_PAST_DAYS)
+  end = today + timedelta(days=entry.future_days if entry.future_days is not None else _DEFAULT_FUTURE_DAYS)
+
+  cal = Calendar(calendar_name)
+  raw_events = await cal.fetch_events(start, end)
+  return [
+    {
+      'calendar':    entry.name,
+      'start':       e['start'],
+      'end':         e['end'],
+      'all_day':     e['all_day'],
+      'title':       e['title'],
+      'description': e['description'],
+      'uid':         e['uid'],
+    }
+    for e in raw_events
+  ]
+
+
+async def fetch_calendar(entry: CalendarEntry) -> list[dict]:
+  """Fetch one CalendarEntry (with either url or caldav) and return its events."""
+  if entry.caldav is not None:
+    return await _fetch_caldav(entry)
+  url = _webcal_to_https(entry.url)
+  req = urllib.request.Request(url, headers={'User-Agent': 'room_cal/1.0'})
+  with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
+    raw = resp.read()
+  return _parse_ical(raw, entry.name)
+
+
+async def fetch_all() -> dict[str, list[dict]]:
+  """Fetch all configured external calendars. Returns {name: [events]}."""
+  results = {}
+  for entry in get_config().calendars:
+    results[entry.name] = await fetch_calendar(entry)
+  return results

+ 8 - 0
room_cal/ical_feeds/ical_feeds.json

@@ -0,0 +1,8 @@
+{
+  "calendars": [
+    {
+      "name": "Fire",
+      "url": "webcal://cal.4114.us/fire.ics"
+    }
+  ]
+}

+ 262 - 0
room_cal/mcp/__init__.py

@@ -0,0 +1,262 @@
+import asyncio
+from datetime import date
+from fastmcp import FastMCP
+from room_cal._utils import services as _services, safe_json_loads, iter_dates
+from room_cal.cal import DEFAULT_CALENDAR_NAME
+from room_cal._health import caldav_checks, credentials_checks
+from room_cal.pricing import CalPrice
+from room_cal.pricing.calc import (
+  net_to_list, list_to_net, list_to_retail,
+  retail_to_list, net_to_retail, retail_to_net,
+)
+from room_cal.reservations import Reservation
+from room_cal.closed import Closed
+from room_cal.discount import Discount
+
+mcp = FastMCP('room_cal')
+
+_CALC_FROM = {
+  'net_to_list': 'net',   'net_to_retail': 'net',
+  'list_to_net': 'list',  'list_to_retail': 'list',
+  'retail_to_list': 'retail', 'retail_to_net': 'retail',
+}
+_API_SOURCE = {'domain': 'mcp', 'id': ''}
+
+
+@mcp.tool()
+async def health_check() -> dict:
+  """Check CalDAV connection, room calendar reachability, and service credential validity. Returns {caldav_connection, calendar, credentials}."""
+  (conn, cal_result), creds = await asyncio.gather(
+    caldav_checks(DEFAULT_CALENDAR_NAME),
+    credentials_checks(),
+  )
+  return {
+    'caldav_connection': conn.to_dict(),
+    'calendar': cal_result.to_dict(),
+    'credentials': {k: v.to_dict() for k, v in creds.items()},
+  }
+
+
+@mcp.tool()
+async def list_services() -> list[dict]:
+  return [{'key': s.key, 'name': s.name} for s in _services()]
+
+
+@mcp.tool()
+async def convert_price(transformation: str, service: str, amount: int) -> dict:
+  """Convert a price. transformation: net_to_list, list_to_net, list_to_retail, retail_to_list, net_to_retail, retail_to_net. Returns {net, list, retail}."""
+  from_type = _CALC_FROM.get(transformation)
+  if from_type is None:
+    raise ValueError(f'Unknown transformation: {transformation!r}')
+  if from_type == 'net':
+    return {'net': amount, 'list': net_to_list(service, amount), 'retail': net_to_retail(service, amount)}
+  if from_type == 'list':
+    return {'net': list_to_net(service, amount), 'list': amount, 'retail': list_to_retail(service, amount)}
+  return {'net': retail_to_net(service, amount), 'list': retail_to_list(service, amount), 'retail': amount}
+
+
+@mcp.tool()
+async def get_prices(start_date: str, end_date: str) -> dict:
+  """Get price dicts for a date range (YYYY-MM-DD). Returns a dict keyed by date string; None for dates with no price set."""
+  price_range = await CalPrice(DEFAULT_CALENDAR_NAME).get(date.fromisoformat(start_date), date.fromisoformat(end_date))
+  return {k: v.to_dict() if v else None for k, v in price_range.items()}
+
+
+@mcp.tool()
+async def set_price(target_date: str, value: float) -> None:
+  """Set the net price for a date (YYYY-MM-DD)."""
+  await CalPrice(DEFAULT_CALENDAR_NAME).set(date.fromisoformat(target_date), value)
+
+
+
+
+@mcp.tool()
+async def get_reservation(target_date: str) -> dict | None:
+  """Get the reservation covering a date (checkin ≤ date < checkout), or null."""
+  d = date.fromisoformat(target_date)
+  for ev in await Reservation(DEFAULT_CALENDAR_NAME).list(d, d):
+    if ev['start'].date() <= d < ev['end'].date():
+      xd = safe_json_loads(ev['x_data']) or {}
+      xd['checkin_date'] = ev['start'].date().isoformat()
+      xd['checkout_date'] = ev['end'].date().isoformat()
+      return xd
+  return None
+
+
+@mcp.tool()
+async def get_reservations_range(start_date: str, end_date: str) -> dict:
+  """Get reservations for each date in a range. Returns {date: dict or null}."""
+  start = date.fromisoformat(start_date)
+  end = date.fromisoformat(end_date)
+  events = await Reservation(DEFAULT_CALENDAR_NAME).list(start, end)
+  result: dict[str, dict | None] = {str(d): None for d in iter_dates(start, end)}
+  for ev in events:
+    checkin = ev['start'].date()
+    checkout = ev['end'].date()
+    xd = safe_json_loads(ev['x_data']) or {}
+    xd['checkin_date'] = checkin.isoformat()
+    xd['checkout_date'] = checkout.isoformat()
+    for d in iter_dates(start, end):
+      if checkin <= d < checkout:
+        result[str(d)] = xd
+  return result
+
+
+@mcp.tool()
+async def get_closed(target_date: str) -> list[str]:
+  """Get closed service keys for a date. Empty list means open."""
+  return await Closed(DEFAULT_CALENDAR_NAME).get(date.fromisoformat(target_date)) or []
+
+
+@mcp.tool()
+async def get_closed_range(start_date: str, end_date: str) -> dict:
+  """Get closed services for each date in a range. Returns {date: [service, ...]}."""
+  start = date.fromisoformat(start_date)
+  end = date.fromisoformat(end_date)
+  events = await Closed(DEFAULT_CALENDAR_NAME).list(start, end)
+  by_date: dict[str, list] = {str(d): [] for d in iter_dates(start, end)}
+  for ev in events:
+    d = ev['start'].date()
+    if start <= d <= end:
+      xd = safe_json_loads(ev['x_data'])
+      by_date[str(d)] = (xd or {}).get('closed_services', [])
+  return by_date
+
+
+@mcp.tool()
+async def set_closed(target_date: str, services: list[str]) -> None:
+  """Block a date for the given services. Empty list unblocks the date."""
+  await Closed(DEFAULT_CALENDAR_NAME).set(date.fromisoformat(target_date), services, source=_API_SOURCE)
+
+
+@mcp.tool()
+async def get_discount(target_date: str) -> dict | None:
+  """Get discount for a date, or null if none set."""
+  return await Discount(DEFAULT_CALENDAR_NAME).get(date.fromisoformat(target_date))
+
+
+@mcp.tool()
+async def get_discounts_range(start_date: str, end_date: str) -> dict:
+  """Get discounts for each date in a range. Returns {date: {type, value} or null}."""
+  start = date.fromisoformat(start_date)
+  end = date.fromisoformat(end_date)
+  events = await Discount(DEFAULT_CALENDAR_NAME).list(start, end)
+  by_date: dict[str, dict | None] = {str(d): None for d in iter_dates(start, end)}
+  for ev in events:
+    d = ev['start'].date()
+    if start <= d <= end:
+      xd = safe_json_loads(ev['x_data'])
+      if xd:
+        by_date[str(d)] = {'type': xd.get('type'), 'value': xd.get('value')}
+  return by_date
+
+
+@mcp.tool()
+async def set_discount(target_date: str, discount_type: str, value: int) -> None:
+  """Set a discount. discount_type: 'percent' or 'flat'."""
+  await Discount(DEFAULT_CALENDAR_NAME).set(
+    date.fromisoformat(target_date), discount_type, value, source=_API_SOURCE
+  )
+
+
+@mcp.tool()
+async def delete_discount(target_date: str) -> None:
+  """Remove the discount on a date."""
+  await Discount(DEFAULT_CALENDAR_NAME).delete_on(date.fromisoformat(target_date))
+
+
+@mcp.tool()
+async def reload_caches() -> dict:
+  """Clear all in-memory CalDAV event caches so the next request re-fetches from the server. Returns {cleared_calendars}."""
+  from room_cal.cal import clear_caches
+  count = clear_caches()
+  return {'cleared_calendars': count}
+
+
+@mcp.tool()
+async def get_chat_thread(service_key: str, chat_id: str) -> dict:
+  """Fetch recent messages for a reservation thread. Returns {chat_id, messages: [{id, created_at, body, sender}], is_reply_needed?}."""
+  from room_cal.service_api import ServiceAPI
+  from room_cal.service_api.types import ServiceAuthError
+  api = ServiceAPI()
+  try:
+    client = api.chat_client(service_key)
+  except KeyError:
+    raise ValueError(f'unknown service: {service_key!r}')
+  except TypeError:
+    raise ValueError(f'{service_key!r} does not support chat')
+  try:
+    thread = await client.get_thread(chat_id)
+  except ServiceAuthError as e:
+    raise RuntimeError(f'auth error: {e}')
+  chat_id_out = getattr(thread, 'thread_id', None) or getattr(thread, 'reservation_id', None)
+  result: dict = {
+    'chat_id': chat_id_out,
+    'messages': [
+      {
+        'id': m.id,
+        'created_at': getattr(m, 'created_at_ms', None) or getattr(m, 'created_at', None),
+        'body': m.body,
+        'sender': getattr(m, 'sender_account_id', None) or getattr(m, 'sender', None),
+      }
+      for m in thread.messages
+    ],
+  }
+  if hasattr(thread, 'is_reply_needed'):
+    result['is_reply_needed'] = thread.is_reply_needed
+  return result
+
+
+@mcp.tool()
+async def send_chat_message(service_key: str, chat_id: str, body: str) -> None:
+  """Send a text message to a guest conversation thread."""
+  from room_cal.service_api import ServiceAPI
+  from room_cal.service_api.types import ServiceAuthError
+  api = ServiceAPI()
+  try:
+    client = api.chat_client(service_key)
+  except KeyError:
+    raise ValueError(f'unknown service: {service_key!r}')
+  except TypeError:
+    raise ValueError(f'{service_key!r} does not support chat')
+  try:
+    await client.send_message(chat_id, body)
+  except ServiceAuthError as e:
+    raise RuntimeError(f'auth error: {e}')
+
+
+import importlib
+import pkgutil
+import room_cal.actions as _actions_pkg
+
+_ACTIONS: dict[str, tuple[str, str, list[str]]] = {}
+for _info in pkgutil.iter_modules(_actions_pkg.__path__):
+  if _info.name.startswith('_'):
+    continue
+  _mod = importlib.import_module(f'room_cal.actions.{_info.name}')
+  _spec = getattr(_mod, 'ACTION_SPEC', None)
+  if _spec:
+    _route_name, _allowed_keys = _spec
+    _ACTIONS[_route_name] = (f'room_cal.actions.{_info.name}', _info.name, _allowed_keys)
+
+@mcp.tool()
+async def run_action(
+  action: str,
+  start_date: str,
+  end_date: str | None = None,
+  feed_name: str | None = None,
+  title: str | None = None,
+  service: str | None = None,
+) -> object:
+  """Run a room_cal action over a date range. action must be one of the names in _ACTIONS (auto-discovered from room_cal.actions). Optional kwargs: feed_name, title (fetch-feed); service (fetch-prices)."""
+  from datetime import date as _date
+  if action not in _ACTIONS:
+    raise ValueError(f'Unknown action: {action!r}')
+  module_path, fn_name, allowed_keys = _ACTIONS[action]
+  mod = importlib.import_module(module_path)
+  fn = getattr(mod, fn_name)
+  start = _date.fromisoformat(start_date)
+  end = _date.fromisoformat(end_date) if end_date else start
+  all_kwargs = {'feed_name': feed_name, 'title': title, 'service': service}
+  kwargs = {k: v for k, v in all_kwargs.items() if k in allowed_keys and v is not None}
+  return await fn(start, end, **kwargs)

+ 5 - 0
room_cal/mcp/_server.py

@@ -0,0 +1,5 @@
+from room_cal.mcp import mcp
+
+
+def main() -> None:
+  mcp.run()

+ 1 - 0
room_cal/pricing/__init__.py

@@ -0,0 +1 @@
+from .price_calendar import CalPrice, PriceData, PriceList

+ 97 - 0
room_cal/pricing/calc.py

@@ -0,0 +1,97 @@
+from room_cal._utils import ServiceConfig, get_config
+
+
+def _resolve(service_key: str) -> tuple[ServiceConfig, float]:
+  cfg = get_config()
+  svc = cfg.services.get(service_key)
+  if svc is None:
+    raise ValueError(f"Unknown service key: {service_key!r}")
+  return svc, cfg.global_.taxes.sales
+
+
+def _n2l(svc: ServiceConfig, sales_tax: float, net: int) -> int:
+  wrong_rate = svc.taxes.wrong if svc.taxes.wrong is not None else sales_tax
+  return round(net / (1 - svc.fees.commission - sales_tax + wrong_rate))
+
+
+def _l2n(svc: ServiceConfig, sales_tax: float, list_: int) -> int:
+  net = float(list_)
+  net -= list_ * svc.fees.commission
+  if svc.taxes.wrong is not None:
+    net -= list_ * sales_tax - list_ * svc.taxes.wrong
+  return round(net)
+
+
+def _l2r(svc: ServiceConfig, sales_tax: float, list_: int) -> int:
+  retail = float(list_)
+  retail += retail * svc.fees.client_fee
+  if svc.taxes.wrong is not None:
+    retail += retail * svc.taxes.wrong
+  else:
+    retail += retail * sales_tax
+  if svc.taxes.flat_tax:
+    retail += svc.taxes.flat_tax
+  return round(retail)
+
+
+def _r2l(svc: ServiceConfig, sales_tax: float, retail: int) -> int:
+  list_ = float(retail)
+  if svc.taxes.flat_tax:
+    list_ -= svc.taxes.flat_tax
+  if svc.taxes.wrong is not None:
+    list_ = list_ / (1 + svc.taxes.wrong)
+  else:
+    list_ = list_ / (1 + sales_tax)
+  if svc.fees.client_fee:
+    list_ = list_ / (1 + svc.fees.client_fee)
+  return round(list_)
+
+
+def calc_details(service_key: str, list_: int) -> dict:
+  svc, sales_tax = _resolve(service_key)
+  d = {}
+  if svc.fees.commission:
+    d['Commission'] = round(list_ * svc.fees.commission, 2)
+  if svc.fees.client_fee:
+    d['Client Fee'] = round(list_ * svc.fees.client_fee, 2)
+  if svc.taxes.wrong is not None:
+    d['Sales Tax'] = round(list_ * sales_tax, 2)
+    d['Wrong Tax'] = round(list_ * svc.taxes.wrong, 2)
+    loss = round(list_ * sales_tax - list_ * svc.taxes.wrong, 2)
+    if loss != 0:
+      d['Wrong Tax Loss'] = loss
+  else:
+    d['Sales Tax'] = round(list_ * sales_tax, 2)
+  if svc.taxes.flat_tax:
+    d['Flat Tax'] = svc.taxes.flat_tax
+  return d
+
+
+def net_to_list(service_key: str, net: int) -> int:
+  svc, tax = _resolve(service_key)
+  return _n2l(svc, tax, net)
+
+
+def list_to_net(service_key: str, list_: int) -> int:
+  svc, tax = _resolve(service_key)
+  return _l2n(svc, tax, list_)
+
+
+def list_to_retail(service_key: str, list_: int) -> int:
+  svc, tax = _resolve(service_key)
+  return _l2r(svc, tax, list_)
+
+
+def retail_to_list(service_key: str, retail: int) -> int:
+  svc, tax = _resolve(service_key)
+  return _r2l(svc, tax, retail)
+
+
+def net_to_retail(service_key: str, net: int) -> int:
+  svc, tax = _resolve(service_key)
+  return _l2r(svc, tax, _n2l(svc, tax, net))
+
+
+def retail_to_net(service_key: str, retail: int) -> int:
+  svc, tax = _resolve(service_key)
+  return _l2n(svc, tax, _r2l(svc, tax, retail))

+ 169 - 0
room_cal/pricing/price_calendar.py

@@ -0,0 +1,169 @@
+import json
+from dataclasses import dataclass, field
+from datetime import date
+from room_cal.cal import CalendarBacked, DEFAULT_CALENDAR_NAME
+from room_cal._utils import safe_json_loads, filter_events, services as _services, get_config
+from room_cal.discount import DiscountType
+from . import calc
+
+_PREFIX = "Price: $"
+_UNSET = object()
+
+
+@dataclass
+class PriceData:
+  base: int
+  net: int
+  discount: int | None = None
+  discount_type: DiscountType | None = None
+  discount_value: int | None = None
+  service_prices: dict[str, int] = field(default_factory=dict)
+  service_retail: dict[str, int] = field(default_factory=dict)
+
+  @classmethod
+  def from_dict(cls, d: dict, service_keys: list[str]) -> 'PriceData':
+    base = d['base'] if 'base' in d else d.get('net')
+    return cls(
+      base=base,
+      net=d['net'],
+      discount=d.get('discount'),
+      discount_type=DiscountType(d['discount_type']) if d.get('discount_type') else None,
+      discount_value=d.get('discount_value'),
+      service_prices={k: d[k] for k in service_keys if k in d},
+      service_retail={k: d[f'{k}_retail'] for k in service_keys if f'{k}_retail' in d},
+    )
+
+  def to_dict(self) -> dict:
+    d: dict = {'base': self.base, 'net': self.net}
+    if self.discount is not None:
+      d['discount'] = self.discount
+      d['discount_type'] = self.discount_type
+      d['discount_value'] = self.discount_value
+    for k, v in self.service_prices.items():
+      d[k] = v
+    for k, v in self.service_retail.items():
+      d[f'{k}_retail'] = v
+    return d
+
+
+PriceList = dict[str, 'PriceData | None']
+
+
+class CalPrice(CalendarBacked):
+  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME):
+    super().__init__(calendar_name)
+    self._services = [s.key for s in _services()]
+
+  async def _get_prices(self, target_date: date) -> 'PriceData | None':
+    events = await self._cal.fetch_date_range(target_date, target_date)
+    for e in filter_events(events, _PREFIX, all_day=True):
+      d = safe_json_loads(e['x_data'])
+      if d is not None:
+        return PriceData.from_dict(d, self._services)
+    return None
+
+  async def _put_prices(self, target_date: date, prices: 'PriceData', *, existing=_UNSET) -> None:
+    title = f"{_PREFIX}{prices.net}"
+    description = _build_description(prices)
+    x_data = json.dumps(prices.to_dict())
+    if existing is _UNSET:
+      events = await self._cal.fetch_date_range(target_date, target_date)
+      matches = filter_events(events, _PREFIX, all_day=True, require_x_data=False)
+      existing = matches[0] if matches else None
+    if existing:
+      if title != existing.get('title') or description != existing.get('description') or x_data != existing.get('x_data'):
+        await self._cal.update_event(existing, title=title, description=description, x_data=x_data)
+    else:
+      await self._cal.create_event(target_date, title, description, x_data=x_data)
+
+  async def set(self, target_date: date, base: float | int, *, existing=_UNSET, discount_rec=_UNSET) -> 'PriceData':
+    """Compute and write a price event. Returns the PriceData that was written.
+
+    Pass existing=<event> to skip the internal event lookup.
+    Pass discount_rec=<DiscountData|None> to skip the per-day Discount.get call.
+    """
+    from room_cal.discount import Discount
+    base = round(base)
+    if discount_rec is _UNSET:
+      discount_rec = await Discount(self._calendar_name).get(target_date)
+
+    discount = 0
+    if discount_rec:
+      if discount_rec.type == DiscountType.FLAT:
+        discount = int(discount_rec.value)
+      else:
+        discount = discount_rec.value * base / 100
+
+    net = round(base - discount)
+    svc_prices: dict[str, int] = {}
+    svc_retail: dict[str, int] = {}
+    for service in self._services:
+      list_price = calc.net_to_list(service, net)
+      svc_prices[service] = list_price
+      svc_retail[service] = calc.list_to_retail(service, list_price)
+
+    prices = PriceData(
+      base=base,
+      net=net,
+      discount=discount if discount_rec else None,
+      discount_type=discount_rec.type if discount_rec else None,
+      discount_value=discount_rec.value if discount_rec else None,
+      service_prices=svc_prices,
+      service_retail=svc_retail,
+    )
+    await self._put_prices(target_date, prices, existing=existing)
+    return prices
+
+  async def list(self, start_date: date, end_date: date) -> list[dict]:
+    """Return all price all-day events in the date range."""
+    events = await self._cal.fetch_date_range(start_date, end_date)
+    return filter_events(events, _PREFIX, all_day=True, require_x_data=False)
+
+  async def get(self, start_date: date, end_date: date) -> 'PriceList':
+    """Return prices for all dates in range.
+
+    Dates with no calendar event get an in-memory default (weekday/weekend from
+    config) — no event is written. Use fill_prices action to persist defaults.
+    """
+    from room_cal._utils import iter_dates
+    events = await self._cal.fetch_date_range(start_date, end_date)
+    by_date: PriceList = {}
+    for ev in filter_events(events, _PREFIX, all_day=True):
+      d = ev['start'].date()
+      if start_date <= d <= end_date:
+        parsed = safe_json_loads(ev['x_data'])
+        if parsed is not None:
+          by_date[str(d)] = PriceData.from_dict(parsed, self._services)
+
+    prices_cfg = get_config().global_.prices
+    for d in iter_dates(start_date, end_date):
+      ds = str(d)
+      if ds not in by_date:
+        base = prices_cfg.weekend if d.weekday() in (4, 5) else prices_cfg.weekday
+        svc_prices: dict[str, int] = {}
+        svc_retail: dict[str, int] = {}
+        for svc in self._services:
+          lp = calc.net_to_list(svc, base)
+          svc_prices[svc] = lp
+          svc_retail[svc] = calc.list_to_retail(svc, lp)
+        by_date[ds] = PriceData(base=base, net=base, service_prices=svc_prices, service_retail=svc_retail)
+
+    return by_date
+
+
+def _build_description(prices: 'PriceData') -> str:
+  lines = [f"Net: ${prices.net}"]
+  if prices.base != prices.net:
+    lines.append(f"Base: ${prices.base}")
+  if prices.discount is not None:
+    if prices.discount_type == DiscountType.PERCENT:
+      lines.append(f"Discount: ${prices.discount} / {prices.discount_value}%")
+    else:
+      lines.append(f"Discount: ${prices.discount}")
+  for k in sorted(prices.service_prices):
+    retail = prices.service_retail.get(k)
+    if retail is not None:
+      lines.append(f"{k.capitalize()}: ${prices.service_prices[k]} / ${retail}")
+    else:
+      lines.append(f"{k.capitalize()}: ${prices.service_prices[k]}")
+  return '\n'.join(lines)

+ 110 - 0
room_cal/reservations/__init__.py

@@ -0,0 +1,110 @@
+import json
+from dataclasses import dataclass, field
+from datetime import date, datetime
+from zoneinfo import ZoneInfo
+from room_cal.cal import CalendarBacked, DEFAULT_CALENDAR_NAME
+from room_cal._utils import EventSource, safe_json_loads, filter_events, service_name, get_config
+from room_cal._mnemonic import wifi_key as _gen_wifi_key, door_code as _gen_door_code
+
+_PREFIX = 'Guest: '
+
+
+@dataclass
+class ReservationData:
+  guest_name: str | None
+  service: str
+  reservation_id: str
+  service_data: dict[str, str] = field(default_factory=dict)
+  chat_id: str | None = None
+  wifi_key: str | None = None
+  door_code: str | None = None
+
+
+def _property_cfg():
+  return get_config().global_.property
+
+
+class Reservation(CalendarBacked, EventSource):
+  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, cal=None):
+    CalendarBacked.__init__(self, calendar_name, cal=cal)
+    EventSource.__init__(self, 'reservations')
+
+  async def set(self, data: dict) -> None:
+    checkin_date = data['checkin_date']
+    checkout_date = data['checkout_date']
+    if checkout_date <= checkin_date:
+      raise ValueError(f"checkout_date ({checkout_date}) must be after checkin_date ({checkin_date})")
+
+    existing = await self._find(data['reservation_id'], checkin_date, checkout_date)
+    if existing and (existing['start'].date() != checkin_date or existing['end'].date() != checkout_date):
+      await self._cal.delete_event(existing)
+      existing = None
+
+    existing_xd = safe_json_loads(existing['x_data']) if existing else None
+    wk = data.get('wifi_key') or (existing_xd or {}).get('wifi_key') or _gen_wifi_key()
+    dc = data.get('door_code') or (existing_xd or {}).get('door_code') or _gen_door_code()
+
+    x_data_dict = {k: data.get(k) for k in ['guest_name', 'service', 'reservation_id']}
+    x_data_dict['service_data'] = data.get('service_data') or {}
+    x_data_dict['chat_id'] = data.get('chat_id')
+    x_data_dict['wifi_key'] = wk
+    x_data_dict['door_code'] = dc
+    x_data = json.dumps(x_data_dict)
+
+    first_name = (data.get('guest_name') or '').split()[0] or 'Guest'
+    title = f"{_PREFIX}{first_name}"
+    description = _build_description(data, wk, dc)
+
+    if existing:
+      if title != existing.get('title') or description != existing.get('description') or x_data != existing.get('x_data'):
+        await self._cal.update_event(existing, title=title, description=description, x_data=x_data)
+    else:
+      prop = _property_cfg()
+      tz = ZoneInfo(prop.timezone)
+      dtstart = datetime(checkin_date.year, checkin_date.month, checkin_date.day, prop.checkin_hour, 0, tzinfo=tz)
+      dtend = datetime(checkout_date.year, checkout_date.month, checkout_date.day, prop.checkout_hour, 0, tzinfo=tz)
+      await self._cal.create_timed_event(dtstart, dtend, title, description, x_data=x_data)
+
+  async def get(self, reservation_id: str, checkin_date: date, checkout_date: date) -> 'ReservationData | None':
+    event = await self._find(reservation_id, checkin_date, checkout_date)
+    if event is None:
+      return None
+    d = safe_json_loads(event['x_data'])
+    if d is None:
+      return None
+    return ReservationData(
+      guest_name=d.get('guest_name'),
+      service=d.get('service', ''),
+      reservation_id=d.get('reservation_id', ''),
+      service_data=d.get('service_data') or {},
+      chat_id=d.get('chat_id'),
+      wifi_key=d.get('wifi_key'),
+      door_code=d.get('door_code'),
+    )
+
+  async def list(self, start_date: date, end_date: date) -> list[dict]:
+    """Return all guest reservation events in the CalDAV calendar within the date range."""
+    events = await self._cal.fetch_date_range(start_date, end_date)
+    return filter_events(events, _PREFIX, all_day=False)
+
+  async def delete(self, event: dict) -> None:
+    await self._cal.delete_event(event)
+
+  async def _find(self, reservation_id: str, checkin_date: date, checkout_date: date) -> dict | None:
+    events = await self._cal.fetch_date_range(checkin_date, checkout_date)
+    for e in filter_events(events, _PREFIX, all_day=False):
+      d = safe_json_loads(e['x_data'])
+      if d and d.get('reservation_id') == reservation_id:
+        return e
+    return None
+
+
+def _build_description(data: dict, wk: str, dc: str) -> str:
+  svc = data['service']
+  return (
+    f"Name: {data.get('guest_name', '')}\n"
+    f"Service: {service_name(svc)}\n"
+    f"Reservation ID: {data['reservation_id']}\n"
+    f"Door: {dc}\n"
+    f"WiFi: {wk}"
+  )

+ 98 - 0
room_cal/rules/__init__.py

@@ -0,0 +1,98 @@
+"""Policy rules engine.
+
+Three rule types: 'discount', 'closed', and 'price'. Each type has a registry of
+named rules, auto-discovered from sub-packages by RULE_NAME.
+
+Rule contract:
+  async def run(start_date, end_date, data: dict[date, Any]) -> dict[date, Any]
+
+  - Generator rules ignore `data` and return fresh records.
+  - Transformer rules read `data` and return modified records.
+
+Each rule receives the accumulated dict and must return its complete intended output
+(the result replaces the accumulator). Generator rules ignore the input and return
+fresh records; transformer/filter rules read the input and return a modified copy.
+
+Pipelines are named sequences of rules configured in config.json under
+rules.pipelines. Each entry: {"type": "discount"|"closed"|"price", "rules": [name, ...]}.
+"""
+from datetime import date
+from typing import Any
+
+from room_cal._utils import EventSource, get_config
+from room_cal.rules import discount as _discount
+from room_cal.rules import closed as _closed
+from room_cal.rules import price as _price
+
+_source = EventSource('rules')
+from room_cal.closed import ClosedList  # re-export
+from room_cal.discount import DiscountList  # re-export
+from room_cal.rules.price import PriceRuleList  # re-export
+
+_REGISTRIES: dict[str, dict] = {
+  'discount': _discount._REGISTRY,
+  'closed': _closed._REGISTRY,
+  'price': _price._REGISTRY,
+}
+
+
+def list_rules(rule_type: str) -> list[str]:
+  return list(_REGISTRIES.get(rule_type, {}))
+
+
+def _pipelines():
+  return get_config().rules.pipelines
+
+
+def list_pipelines(*, type_: str | None = None) -> list[str]:
+  pls = _pipelines()
+  if type_ is None:
+    return list(pls)
+  return [name for name, p in pls.items() if p.type == type_]
+
+
+def pipeline_type(name: str) -> str:
+  return _pipelines()[name].type
+
+
+async def run_rule(
+  rule_type: str,
+  name: str,
+  start_date: date,
+  end_date: date,
+  data: dict[date, Any],
+) -> dict[date, Any]:
+  registry = _REGISTRIES.get(rule_type)
+  if registry is None:
+    raise ValueError(f"Unknown rule type: {rule_type!r}")
+  mod = registry.get(name)
+  if mod is None:
+    raise ValueError(f"Unknown {rule_type} rule: {name!r}")
+  return await mod.run(start_date, end_date, data)
+
+
+async def run_rules(
+  rule_type: str,
+  names: list[str],
+  start_date: date,
+  end_date: date,
+  initial_data: dict[date, Any] | None = None,
+) -> dict[date, Any]:
+  """Run a sequence of named rules, merging each result into the accumulated data."""
+  data: dict[date, Any] = dict(initial_data) if initial_data else {}
+  for name in names:
+    data = await run_rule(rule_type, name, start_date, end_date, data)
+  return data
+
+
+async def run_pipeline(
+  name: str,
+  start_date: date,
+  end_date: date,
+  initial_data: dict[date, Any] | None = None,
+) -> dict[date, Any]:
+  """Run the named pipeline from config and return the merged result."""
+  p = _pipelines().get(name)
+  if p is None:
+    raise ValueError(f"Unknown pipeline: {name!r}")
+  return await run_rules(p.type, p.rules, start_date, end_date, initial_data)

+ 35 - 0
room_cal/rules/_utils.py

@@ -0,0 +1,35 @@
+from datetime import date, timedelta
+
+from room_cal.closed import Closed
+from room_cal.reservations import Reservation
+from room_cal._utils import iter_dates, safe_json_loads
+
+
+async def open_dates(start_date: date, end_date: date) -> set[date]:
+  """Return the set of dates in [start_date, end_date] with no reservation or closure.
+
+  Closures written by a rule (domain='rules') are excluded so they don't suppress
+  the rule that would re-generate them on the next run.
+  """
+  closed_cal = Closed()
+  res_cal = Reservation()
+  closed_events, res_events = (
+    await closed_cal.list(start_date, end_date),
+    await res_cal.list(start_date, end_date),
+  )
+
+  def _rule_sourced(e: dict) -> bool:
+    d = safe_json_loads(e.get('x_data', ''))
+    domain = (d.get('source') or {}).get('domain') if d else None
+    return domain == 'rules'
+
+  closed_dates: set[date] = {e['start'].date() for e in closed_events if not _rule_sourced(e)}
+  booked_dates: set[date] = set()
+  for e in res_events:
+    for d in iter_dates(e['start'].date(), e['end'].date() - timedelta(days=1)):
+      booked_dates.add(d)
+
+  return {
+    d for d in iter_dates(start_date, end_date)
+    if d not in closed_dates and d not in booked_dates
+  }

+ 11 - 0
room_cal/rules/closed/__init__.py

@@ -0,0 +1,11 @@
+import importlib
+import pkgutil
+
+_REGISTRY: dict[str, object] = {}
+for _info in pkgutil.iter_modules(__path__):
+  if _info.name.startswith('_'):
+    continue
+  _mod = importlib.import_module(f'{__name__}.{_info.name}')
+  _name = getattr(_mod, 'RULE_NAME', None)
+  if _name:
+    _REGISTRY[_name] = _mod

+ 20 - 0
room_cal/rules/closed/future_block.py

@@ -0,0 +1,20 @@
+"""Block open dates that fall beyond the configured days_out horizon."""
+from datetime import date, timedelta
+
+from room_cal._utils import get_config, iter_dates, local_today, services as _services
+from room_cal.closed import ClosedData, ClosedList
+from room_cal.rules._utils import open_dates
+
+RULE_NAME = 'future_block'
+
+
+async def run(start_date: date, end_date: date, data: ClosedList) -> ClosedList:
+  days_out = get_config().rules.future_block.days_out
+  threshold = local_today() + timedelta(days=days_out)
+  open_ = await open_dates(start_date, end_date)
+  all_keys = sorted(s.key for s in _services())
+  return {
+    d: ClosedData(services=all_keys if d > threshold else [])
+    for d in iter_dates(start_date, end_date)
+    if d in open_
+  }

+ 11 - 0
room_cal/rules/discount/__init__.py

@@ -0,0 +1,11 @@
+import importlib
+import pkgutil
+
+_REGISTRY: dict[str, object] = {}
+for _info in pkgutil.iter_modules(__path__):
+  if _info.name.startswith('_'):
+    continue
+  _mod = importlib.import_module(f'{__name__}.{_info.name}')
+  _name = getattr(_mod, 'RULE_NAME', None)
+  if _name:
+    _REGISTRY[_name] = _mod

+ 42 - 0
room_cal/rules/discount/_utils.py

@@ -0,0 +1,42 @@
+import dataclasses
+from datetime import date
+from typing import Callable
+
+from room_cal.discount import DiscountData, DiscountList
+
+
+def apply_ramp(ramp_start: date, ramp_end: date, data: DiscountList) -> DiscountList:
+  """Scale values from full at ramp_start to zero at ramp_end (exclusive).
+
+  Dates outside [ramp_start, ramp_end) are passed through unchanged.
+  Works for any discount type (percent, fixed, etc.).
+  """
+  span = (ramp_end - ramp_start).days
+  if span <= 0:
+    return dict(data)
+  result: DiscountList = {}
+  for d, rec in data.items():
+    if ramp_start <= d < ramp_end:
+      offset = (d - ramp_start).days
+      scale = (span - offset) / span
+      rec = dataclasses.replace(rec, value=round(rec.value * scale))
+    result[d] = rec
+  return result
+
+
+def merge_max(
+  a: DiscountList,
+  b: DiscountList,
+  prefer: Callable[[DiscountData, DiscountData], bool] = lambda x, y: x.value > y.value,
+) -> DiscountList:
+  """Merge two DiscountLists, keeping the preferred record for each date.
+
+  Dates present in only one list are passed through unchanged.
+  For dates in both, prefer(a, b) decides: True keeps a, False keeps b.
+  Default keeps whichever has the higher value; ties go to a.
+  """
+  result: DiscountList = {**a}
+  for d, rec_b in b.items():
+    if d not in result or prefer(rec_b, result[d]):
+      result[d] = rec_b
+  return result

+ 46 - 0
room_cal/rules/discount/fill_month.py

@@ -0,0 +1,46 @@
+"""Extend discounts to all open days within the configured window.
+
+Transformer rule: receives an accumulated DiscountList and adds a flat discount_pct
+for any open date not already discounted at a higher rate. Ramped down over the
+last ramp_days of the window.
+"""
+from datetime import date, timedelta
+
+from room_cal._utils import get_config, iter_dates, local_today
+from room_cal.discount import DiscountData, DiscountList, DiscountType
+from room_cal.rules._utils import open_dates
+from room_cal.rules.discount._utils import apply_ramp
+
+RULE_NAME = 'fill_month'
+
+
+async def run(start_date: date, end_date: date, data: DiscountList) -> DiscountList:
+  rules = get_config().rules.fill_month
+  today = local_today()
+  window_end = today + timedelta(days=rules.max_days - 1)
+
+  effective_start = max(start_date, today)
+  effective_end = min(end_date, window_end)
+  if effective_start > effective_end:
+    return dict(data)
+
+  fetch_start = effective_start - timedelta(days=1)
+  fetch_end = effective_end + timedelta(days=1)
+  open_ = await open_dates(fetch_start, fetch_end)
+
+  ramp_days = rules.ramp_days
+  ramp_start = window_end - timedelta(days=ramp_days - 1)
+  candidates: DiscountList = {}
+  for d in iter_dates(effective_start, effective_end):
+    if d not in open_:
+      continue
+    candidates[d] = DiscountData(type=DiscountType.PERCENT, value=rules.discount_pct)
+
+  ramped = apply_ramp(ramp_start, window_end + timedelta(days=1), candidates)
+
+  result = dict(data)
+  for d, rec in ramped.items():
+    existing = result.get(d)
+    if existing is None or rec.value > existing.value:
+      result[d] = rec
+  return result

+ 26 - 0
room_cal/rules/discount/no_single_nights.py

@@ -0,0 +1,26 @@
+"""Filter discounts on isolated open nights (both neighbours closed/booked).
+
+Transformer rule: receives an accumulated DiscountList and removes any date
+where neither the previous nor the following night is open.
+"""
+from datetime import date, timedelta
+
+from room_cal.discount import DiscountList
+from room_cal.rules._utils import open_dates
+
+RULE_NAME = 'no_single_nights'
+
+
+async def run(start_date: date, end_date: date, data: DiscountList) -> DiscountList:
+  if not data:
+    return {}
+
+  dates = sorted(data)
+  fetch_start = dates[0] - timedelta(days=1)
+  fetch_end = dates[-1] + timedelta(days=1)
+  open_ = await open_dates(fetch_start, fetch_end)
+
+  return {
+    d: rec for d, rec in data.items()
+    if (d - timedelta(days=1)) in open_ or (d + timedelta(days=1)) in open_
+  }

+ 51 - 0
room_cal/rules/discount/short_notice.py

@@ -0,0 +1,51 @@
+"""Generate short-notice discounts for open dates within the configured window.
+
+Discount tiers for each qualifying open date:
+  40% — both the day before and the day after are open
+  30% — day before is open (day after is not)
+  20% — day after is open (day before is not)
+"""
+from datetime import date, timedelta
+
+from room_cal._utils import get_config, iter_dates, local_today
+from room_cal.discount import DiscountData, DiscountList, DiscountType
+from room_cal.rules._utils import open_dates
+from room_cal.rules.discount._utils import apply_ramp
+
+RULE_NAME = 'short_notice'
+
+
+async def run(start_date: date, end_date: date, data: DiscountList) -> DiscountList:
+  rules = get_config().rules.short_notice
+  max_days = rules.max_days
+  today = local_today()
+  window_end = today + timedelta(days=max_days - 1)
+
+  effective_start = max(start_date, today)
+  effective_end = min(end_date, window_end)
+  if effective_start > effective_end:
+    return {}
+
+  fetch_start = effective_start - timedelta(days=1)
+  fetch_end = effective_end + timedelta(days=1)
+  open_ = await open_dates(fetch_start, fetch_end)
+
+  result: DiscountList = {}
+  for d in iter_dates(effective_start, effective_end):
+    if d not in open_:
+      continue
+    before_open = (d - timedelta(days=1)) in open_
+    after_open = (d + timedelta(days=1)) in open_
+    if not before_open and not after_open:
+      continue
+    if before_open and after_open:
+      pct = 40
+    elif before_open:
+      pct = 30
+    else:
+      pct = 20
+    result[d] = DiscountData(type=DiscountType.PERCENT, value=pct)
+
+  ramp_days = rules.ramp_days
+  ramp_start = window_end - timedelta(days=ramp_days - 1)
+  return apply_ramp(ramp_start, window_end + timedelta(days=1), result)

+ 13 - 0
room_cal/rules/price/__init__.py

@@ -0,0 +1,13 @@
+import importlib
+import pkgutil
+
+from room_cal.rules.price._types import PriceRuleData, PriceRuleList  # re-export
+
+_REGISTRY: dict[str, object] = {}
+for _info in pkgutil.iter_modules(__path__):
+  if _info.name.startswith('_'):
+    continue
+  _mod = importlib.import_module(f'{__name__}.{_info.name}')
+  _name = getattr(_mod, 'RULE_NAME', None)
+  if _name:
+    _REGISTRY[_name] = _mod

+ 10 - 0
room_cal/rules/price/_types.py

@@ -0,0 +1,10 @@
+from dataclasses import dataclass
+from datetime import date
+
+
+@dataclass
+class PriceRuleData:
+  min_base: int
+
+
+PriceRuleList = dict[date, PriceRuleData]

+ 48 - 0
room_cal/rules/price/fire.py

@@ -0,0 +1,48 @@
+"""Raise price floors around fire days from the configured iCal feed.
+
+Generator/transformer rule: reads the Fire iCal feed and sets minimum base
+prices for the day-of, day-before, and day-after each fire date. When multiple
+fire dates affect the same calendar date, the highest minimum wins.
+"""
+from datetime import date, timedelta
+
+from room_cal._utils import event_dates, get_config, iter_dates
+from room_cal.rules.price._types import PriceRuleData, PriceRuleList
+
+RULE_NAME = 'fire'
+
+
+async def run(start_date: date, end_date: date, data: PriceRuleList) -> PriceRuleList:
+  cfg = get_config().rules.fire
+  feed_entry = next(
+    (e for e in get_config().calendars if e.name == cfg.feed_name),
+    None,
+  )
+  if feed_entry is None:
+    return dict(data)
+
+  from room_cal.ical_feeds import fetch_calendar
+  events = await fetch_calendar(feed_entry)
+
+  # Collect fire dates in an extended range so adjacent effects are captured.
+  check_start = start_date - timedelta(days=1)
+  check_end = end_date + timedelta(days=1)
+  fire_dates: set[date] = set()
+  for ev in events:
+    for d in event_dates(ev, check_start, check_end):
+      fire_dates.add(d)
+
+  result = dict(data)
+
+  def _apply(d: date, min_base: int) -> None:
+    if start_date <= d <= end_date:
+      existing = result.get(d)
+      if existing is None or min_base > existing.min_base:
+        result[d] = PriceRuleData(min_base=min_base)
+
+  for fire_date in fire_dates:
+    _apply(fire_date - timedelta(days=1), cfg.day_before)
+    _apply(fire_date, cfg.day_of)
+    _apply(fire_date + timedelta(days=1), cfg.day_after)
+
+  return result

+ 13 - 0
room_cal/service_api/__init__.py

@@ -0,0 +1,13 @@
+from room_cal._utils import EventSource
+from .client import ServiceAPI
+from .credentials import check_credentials, set_credentials
+from ._protocols import ChatClient, ServiceClient
+from .types import DayInfo, Reservation, ServiceAuthError
+
+_source = EventSource('service_api')
+
+__all__ = [
+  'ServiceAPI', 'ServiceClient', 'ChatClient',
+  'DayInfo', 'Reservation', 'ServiceAuthError',
+  'set_credentials', 'check_credentials',
+]

+ 492 - 0
room_cal/service_api/_airbnb.py

@@ -0,0 +1,492 @@
+import asyncio
+import base64
+import json
+import os
+import random
+import string
+import uuid
+from datetime import datetime
+from zoneinfo import ZoneInfo
+import httpx
+
+from dataclasses import dataclass
+
+import logging
+
+from ._scripts import refresh_airbnb_cookies
+from room_cal._utils import data_home, get_config, read_env
+from ._utils import month_bounds, months_in_range
+from .credentials import is_stale, mark_stale
+from .types import CalendarEvent, DayInfo, ServiceAuthError, Reservation
+
+_log = logging.getLogger(__name__)
+
+
+@dataclass
+class AirbnbMessage:
+  id: str               # global ID (base64)
+  created_at_ms: str
+  body: str | None      # None for non-text messages (photos, system events, etc.)
+  body_translated: str | None  # Airbnb AI translation; None when not provided
+  sender_account_id: str
+
+
+@dataclass
+class AirbnbThread:
+  thread_id: str              # numeric string, matches Reservation.thread_id
+  messages: list[AirbnbMessage]
+
+
+@dataclass
+class AirbnbData:
+  suggested_prices: dict[str, int]    # date → Airbnb smart-pricing suggestion (USD)
+  calendar_events: list[CalendarEvent]  # holidays and local demand events
+
+_BASE = 'https://www.airbnb.com/api/v3'
+_API_KEY = 'd306zoyjsyarp7ifhu67rjxn52tv0t20'
+_CLIENT_VERSION = 'f3b3e542c2948871cd2fc1c805ad2df39d6d64db'
+_HASHES = {
+  'getDLSHostCalendar':               '048e22675ffa53121628d2d07b519b9b71f5dab342a1ceb75aa72d8814a00ea7',
+  'EditPanelPricingSettingsMutation': 'e27fed4f08dd4ab4c0267c5d6c0a7e4920730deadf48f92feba9b0d08befafa4',
+  'EditPanelAvailabilityMutation':    'b855fc706de32097489d2ea83a113476a273acdff59aad682c837075af45dcc8',
+  'StayHostingDetailsQuery':          '1e81282ae4de44e05bc8b776456644efd4ebcd676451ce7f6c3c5757a1057e02',
+  'getNightlyPriceTips':              '07538bf1a5afc8171f1e7443f0007d7f020142fc7ab60df065e7e1edd1a0b29f',
+  'HostCalendarListingEventsQuery':   '8bfe5f5572a25fdaff5b5790604e1de3aec5f37731fc505684022d590d06e0a8',
+  'ViaductGetThreadAndDataQuery':     'dcb05a1212b5bbd6137456ad562915405b97efbb20e466ed0ecba59577f53d49',
+  'CreateBulkMessagesMutation':       'b0298188a93a73218fecdd512afb7b47c0df3112a4819c236ecc4c0c10c04bd2',
+}
+
+_AUTH_HINT = 'refresh-creds'
+
+
+class AirbnbAuthError(ServiceAuthError):
+  pass
+
+
+def _encode_listing_id(listing_id: str) -> str:
+  return base64.b64encode(f'StayListing:{listing_id}'.encode()).decode()
+
+
+def _trace_id() -> str:
+  return ''.join(random.choices(string.ascii_lowercase + string.digits, k=28))
+
+
+class _AirbnbClient:
+  def __init__(self, cookies: str, listing_id: str, tz: str = 'America/Los_Angeles'):
+    self._cookies = cookies
+    self._listing_id = listing_id
+    self._global_id = _encode_listing_id(listing_id)
+    self._tz = tz
+    self._cache: dict[str, DayInfo] = {}
+    self._fetched_months: set[tuple[int, int]] = set()
+    self._refresh_lock = asyncio.Lock()
+    self._refresh_task: asyncio.Task | None = None
+
+  @classmethod
+  def from_env(cls) -> '_AirbnbClient':
+    env_file = data_home() / 'airbnb.env'
+    env = {**read_env(env_file), **os.environ}
+    cookies = env.get('AIRBNB_COOKIES', '')
+    if not cookies:
+      raise ValueError(f'AIRBNB_COOKIES must be set in {env_file}')
+    listing_id = get_config().services['airbnb'].listing_id
+    if not listing_id:
+      raise ValueError('airbnb.listing_id must be set in config.json')
+    return cls(cookies=cookies, listing_id=listing_id)
+
+  def clear_cache(self) -> None:
+    self._cache.clear()
+    self._fetched_months.clear()
+
+  def _reload_from_env(self) -> None:
+    env_file = data_home() / 'airbnb.env'
+    env = {**read_env(env_file), **os.environ}
+    self._cookies = env.get('AIRBNB_COOKIES', self._cookies)
+    self.clear_cache()
+
+  async def _auto_refresh(self) -> None:
+    _log.warning('Airbnb session may be expired — launching headless Playwright')
+    try:
+      refresh_airbnb_cookies()
+      self._reload_from_env()
+    except ServiceAuthError:
+      mark_stale('airbnb')
+      raise
+
+  async def _ensure_refreshed(self) -> None:
+    async with self._refresh_lock:
+      if self._refresh_task is None or self._refresh_task.done():
+        self._refresh_task = asyncio.create_task(self._auto_refresh())
+      task = self._refresh_task
+    await task
+
+  def _headers(self, referer: str, extra: dict | None = None) -> dict[str, str]:
+    h = {
+      'Cookie': self._cookies,
+      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0',
+      'Accept': '*/*',
+      'Accept-Language': 'en-US,en;q=0.9',
+      'Content-Type': 'application/json',
+      'Referer': referer,
+      'Sec-Fetch-Mode': 'cors',
+      'Sec-Fetch-Dest': 'empty',
+      'Sec-Fetch-Site': 'same-origin',
+      'X-Airbnb-API-Key': _API_KEY,
+      'X-Airbnb-GraphQL-Platform': 'web',
+      'X-Airbnb-GraphQL-Platform-Client': 'minimalist-niobe',
+      'X-Airbnb-Supports-Airlock-V2': 'true',
+      'X-Niobe-Short-Circuited': 'true',
+      'X-Client-Version': _CLIENT_VERSION,
+      'X-CSRF-Token': '',
+      'X-CSRF-Without-Token': '1',
+      'x-airbnb-client-trace-id': _trace_id(),
+      'x-client-request-id': _trace_id(),
+      'x-airbnb-network-log-link': _trace_id(),
+    }
+    if extra:
+      h.update(extra)
+    return h
+
+  def _check_auth(self, body: dict) -> None:
+    if any(
+      e.get('extensions', {}).get('response', {}).get('status') in (400, 401, 403)
+      for e in body.get('errors', [])
+    ):
+      mark_stale('airbnb')
+      raise AirbnbAuthError(f'Airbnb session expired — run: {_AUTH_HINT}')
+
+  async def _gql(self, op: str, h: str, variables: dict, referer: str) -> dict:
+    url = f'{_BASE}/{op}/{h}'
+    params = {
+      'operationName': op,
+      'locale': 'en',
+      'currency': 'USD',
+      'variables': json.dumps(variables),
+      'extensions': json.dumps({'persistedQuery': {'version': 1, 'sha256Hash': h}}),
+    }
+    async def _attempt() -> httpx.Response:
+      async with httpx.AsyncClient() as client:
+        return await client.get(url, params=params, headers=self._headers(referer))
+    try:
+      r = await _attempt()
+    except (httpx.TransportError, OSError, ExceptionGroup) as e:
+      raise AirbnbAuthError(f'Airbnb connection failed — session may be expired, run: {_AUTH_HINT}') from e
+    if r.status_code in (400, 401, 403):
+      await self._ensure_refreshed()
+      r = await _attempt()
+    if r.status_code in (400, 401, 403):
+      mark_stale('airbnb')
+      raise AirbnbAuthError(f'Airbnb session expired (HTTP {r.status_code}) — run: {_AUTH_HINT}')
+    r.raise_for_status()
+    body = r.json()
+    self._check_auth(body)
+    return body
+
+  async def _mutate(self, op: str, h: str, variables: dict, referer: str) -> dict:
+    url = f'{_BASE}/{op}/{h}?operationName={op}&locale=en&currency=USD'
+    payload = {'operationName': op, 'variables': variables,
+               'extensions': {'persistedQuery': {'version': 1, 'sha256Hash': h}}}
+    async def _attempt() -> httpx.Response:
+      async with httpx.AsyncClient() as client:
+        return await client.post(
+          url, json=payload,
+          headers=self._headers(referer, {'Origin': 'https://www.airbnb.com'}),
+        )
+    try:
+      r = await _attempt()
+    except (httpx.TransportError, OSError, ExceptionGroup) as e:
+      raise AirbnbAuthError(f'Airbnb connection failed — session may be expired, run: {_AUTH_HINT}') from e
+    if r.status_code in (400, 401, 403):
+      await self._ensure_refreshed()
+      r = await _attempt()
+    if r.status_code in (400, 401, 403):
+      mark_stale('airbnb')
+      raise AirbnbAuthError(f'Airbnb session expired (HTTP {r.status_code}) — run: {_AUTH_HINT}')
+    r.raise_for_status()
+    body = r.json()
+    self._check_auth(body)
+    return body
+
+  async def _fetch_month_days(self, month: int, year: int) -> list[dict]:
+    start, end = month_bounds(month, year)
+    resp = await self._gql(
+      'getDLSHostCalendar', _HASHES['getDLSHostCalendar'],
+      {
+        'listingId': self._global_id,
+        'startDate': start,
+        'endDate': end,
+        'hostCalendarViewType': 'MONTH_VIEW',
+        'lensTypes': ['NOTE', 'PRICE', 'PROMOTION'],
+        'timeZone': self._tz,
+        'localizationContext': {'timeZone': self._tz, 'firstDayOfWeek': 0},
+        'hostCalendarSectionTypes': ['MAIN_VIEW'],
+        'readConsistency': 'EVENTUAL',
+      },
+      f'https://www.airbnb.com/multicalendar/{self._listing_id}',
+    )
+    section = (
+      resp.get('data', {}).get('presentation', {})
+          .get('hostCalendar', {}).get('sections', {})
+          .get('calendarGridViewSection')
+    ) or {}
+    return section.get('days', [])
+
+  async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
+    if is_stale('airbnb'):
+      raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
+    for month, year in months_in_range(date_from, date_to):
+      if (month, year) not in self._fetched_months:
+        for day in await self._fetch_month_days(month, year):
+          d = day.get('day', '')
+          if d:
+            self._cache[d] = DayInfo(
+              date=d,
+              price=day.get('priceData', {}).get('price'),
+              available=day.get('available', False),
+              booked=day.get('reservationData') is not None,
+              rooms_to_sell=None,
+            )
+        self._fetched_months.add((month, year))
+    return {d: info for d, info in self._cache.items() if date_from <= d <= date_to}
+
+  async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
+    if is_stale('airbnb'):
+      raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
+    booked: dict[str, str] = {}
+    for month, year in months_in_range(date_from, date_to):
+      for day in await self._fetch_month_days(month, year):
+        d = day.get('day', '')
+        res_data = day.get('reservationData')
+        if res_data and date_from <= d <= date_to:
+          booked[d] = res_data.get('confirmationCode', '')
+
+    if not booked:
+      return []
+
+    unique_codes = set(booked.values())
+
+    def _utc_to_local_date(ts: str) -> str:
+      dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
+      return dt.astimezone(ZoneInfo(self._tz)).date().isoformat()
+
+    async def fetch_details(c: str) -> tuple[str, str | None, str | None, str | None, str | None, str | None]:
+      resp = await self._gql(
+        'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
+        {'viewerTimeZoneOffset': 0, 'requestSource': 'RESERVATION_TAB', 'confirmationCode': c},
+        f'https://www.airbnb.com/hosting/reservations/details/{c}',
+      )
+      sections = (
+        resp.get('data', {}).get('presentation', {})
+            .get('hostingDetails', {}).get('stayHostingDetails', {})
+            .get('rootPlacement', [])
+      )
+      facepile = next((s for s in sections if s.get('sectionId') == 'FACEPILE_SECTION'), None)
+      guests = (facepile or {}).get('sectionData', {}).get('guests', [])
+      name = guests[0].get('name') if guests else None
+
+      checkinout = next((s for s in sections if s.get('sectionId') == 'CHECK_IN_CHECK_OUT_SECTION'), None)
+      ci_out_data = (checkinout or {}).get('sectionData') or {}
+      action = ci_out_data.get('checkInAction', {})
+      ci_ts = action.get('checkInTime')
+      co_ts = action.get('checkOutTime')
+      ci = _utc_to_local_date(ci_ts) if ci_ts else None
+      co = _utc_to_local_date(co_ts) if co_ts else None
+      door_code = (ci_out_data.get('airkeyAction') or {}).get('guestPhoneNumberLastFourDigits')
+
+      # thread_id is in footerPlacement FLOATINGFOOTER_SECTION, not rootPlacement
+      details = resp.get('data', {}).get('presentation', {}).get('hostingDetails', {}).get('stayHostingDetails', {})
+      footer_sections = details.get('footerPlacement', [])
+      footer = next((s for s in footer_sections if s.get('sectionId') == 'FLOATINGFOOTER_SECTION'), None)
+      buttons = ((footer or {}).get('sectionData') or {}).get('buttons', [])
+      thread_id = None
+      for btn in buttons:
+        action = btn.get('buttonAction') or {}
+        if action.get('__typename') == 'NavigateToMessageGuest':
+          url = action.get('url', '')
+          m = __import__('re').search(r'/thread/(\d+)', url)
+          if m:
+            thread_id = m.group(1)
+            break
+
+      return c, name, ci, co, door_code, thread_id
+
+    details_list = await asyncio.gather(*[fetch_details(c) for c in unique_codes])
+    details = {c: (name, ci, co, door_code, thread_id) for c, name, ci, co, door_code, thread_id in details_list}
+
+    return sorted(
+      [
+        Reservation(
+          service='airbnb',
+          reservation_id=c,
+          check_in=ci,
+          check_out=co,
+          guest_name=name,
+          door_code=door_code,
+          thread_id=thread_id,
+          service_data={k: v for k, v in {'door_code': door_code, 'thread_id': thread_id}.items() if v is not None},
+          chat_id=thread_id,
+        )
+        for c, (name, ci, co, door_code, thread_id) in details.items()
+        if ci and co
+      ],
+      key=lambda r: r.check_in,
+    )
+
+  async def set_price(self, d: str, price: int) -> None:
+    if is_stale('airbnb'):
+      raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
+    if (cached := self._cache.get(d)) and cached.price == price:
+      return
+    await self._mutate(
+      'EditPanelPricingSettingsMutation', _HASHES['EditPanelPricingSettingsMutation'],
+      {'input': {
+        'listingId': self._listing_id,
+        'selectedDateRanges': [{'startDate': d, 'endDate': d}],
+        'nightlyPriceAmount': price,
+        'turnOnSmartPricing': False,
+        'isCalendarV2': True,
+      }},
+      f'https://www.airbnb.com/multicalendar/{self._listing_id}/edit-selected-dates/{d}/{d}/nightly-price',
+    )
+    self.clear_cache()
+    _log.info('airbnb set_price date=%s price=$%d', d, price)
+
+  async def set_availability(self, d: str, available: bool) -> None:
+    if is_stale('airbnb'):
+      raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
+    if (cached := self._cache.get(d)) and cached.available == available:
+      return
+    await self._mutate(
+      'EditPanelAvailabilityMutation', _HASHES['EditPanelAvailabilityMutation'],
+      {'input': {
+        'listingId': self._listing_id,
+        'selectedDateRanges': [{'startDate': d, 'endDate': d}],
+        'actionType': 'OPEN' if available else 'BLOCK',
+      }, 'blockNotesEnabled': True},
+      f'https://www.airbnb.com/multicalendar/{self._listing_id}/edit-selected-dates/{d}/{d}',
+    )
+    self.clear_cache()
+    _log.info('airbnb set_availability date=%s available=%s', d, available)
+
+  def _global_thread_id(self, thread_id: str) -> str:
+    return base64.b64encode(f'MessageThread:{thread_id}'.encode()).decode()
+
+  async def get_thread(self, thread_id: str) -> AirbnbThread:
+    """Fetch a conversation thread and its messages by numeric thread ID."""
+    if is_stale('airbnb'):
+      raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
+    resp = await self._gql(
+      'ViaductGetThreadAndDataQuery', _HASHES['ViaductGetThreadAndDataQuery'],
+      {
+        'globalThreadId': self._global_thread_id(thread_id),
+        'numRequestedMessages': 50,
+        'getThreadState': True,
+        'getParticipants': True,
+        'getLastReads': True,
+        'getInboxFields': True,
+        'getInboxOnlyFields': False,
+        'getMessageFields': True,
+        'getThreadOnlyFields': True,
+        'forceUgcTranslation': False,
+        'isNovaLite': False,
+        'mockThreadIdentifier': None,
+        'mockMessageTestIdentifier': None,
+        'mockListFooterSlot': None,
+        'forceReturnAllReadReceipts': False,
+        'originType': 'USER_INBOX',
+        'skipOldMessagePreviewFields': False,
+      },
+      f'https://www.airbnb.com/messages/{thread_id}',
+    )
+    raw_messages = (
+      resp.get('data', {}).get('threadData', {})
+          .get('messageData', {}).get('messages', [])
+    )
+    messages = [
+      AirbnbMessage(
+        id=m['id'],
+        created_at_ms=m['createdAtMs'],
+        body=(((m.get('hydratedContent') or {}).get('content') or {}).get('body')),
+        body_translated=(((m.get('hydratedContent') or {}).get('content') or {}).get('bodyTranslated')),
+        sender_account_id=(m.get('account') or {}).get('accountId', ''),
+      )
+      for m in raw_messages
+    ]
+    return AirbnbThread(thread_id=thread_id, messages=messages)
+
+  async def send_message(self, thread_id: str, body: str) -> None:
+    """Send a text message to an existing thread."""
+    if is_stale('airbnb'):
+      raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
+    await self._mutate(
+      'CreateBulkMessagesMutation', _HASHES['CreateBulkMessagesMutation'],
+      {
+        'messageThreadId': thread_id,
+        'messages': [{
+          'content': {'textContent': {'body': body}},
+          'uniqueIdentifier': str(uuid.uuid4()),
+          'loggingSource': {'sourceType': '', 'sourceId': ''},
+          'loggingExtras': {'entries': []},
+        }],
+        'actAs': 'PARTICIPANT',
+        'businessJustification': {'feature': 'USER_INBOX'},
+        'originType': 'USER_INBOX',
+        'senderPlatform': 'WEB',
+        'skipOldMessagePreviewFields': False,
+      },
+      f'https://www.airbnb.com/messages/{thread_id}',
+    )
+
+  async def get_suggested_prices(self, date_from: str, date_to: str) -> dict[str, int]:
+    """Airbnb's per-day smart-pricing suggestions (USD). Only includes days where shouldShowTip=True."""
+    if is_stale('airbnb'):
+      raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
+    resp = await self._gql(
+      'getNightlyPriceTips', _HASHES['getNightlyPriceTips'],
+      {
+        'listingId': self._global_id,
+        'dateRanges': [{'startDate': date_from, 'endDate': date_to}],
+      },
+      f'https://www.airbnb.com/multicalendar/{self._listing_id}',
+    )
+    tips = (
+      resp.get('data', {}).get('presentation', {})
+          .get('pricingGuidance', {}).get('nightlyPriceTip', {})
+          .get('nightlyPriceTip', {}).get('discretePriceTips', [])
+    )
+    return {
+      t['date']: t['priceTip']['intValue']
+      for t in tips
+      if t.get('shouldShowTip') and date_from <= t['date'] <= date_to
+    }
+
+  async def get_service_data(self, date_from: str, date_to: str) -> AirbnbData:
+    suggested_prices, calendar_events = await asyncio.gather(
+      self.get_suggested_prices(date_from, date_to),
+      self.get_calendar_events(),
+    )
+    return AirbnbData(suggested_prices=suggested_prices, calendar_events=calendar_events)
+
+  async def get_calendar_events(self) -> list[CalendarEvent]:
+    """Local events and holidays Airbnb shows on the host calendar (holidays, major events, etc.)."""
+    if is_stale('airbnb'):
+      raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
+    resp = await self._gql(
+      'HostCalendarListingEventsQuery', _HASHES['HostCalendarListingEventsQuery'],
+      {'listingId': self._global_id},
+      f'https://www.airbnb.com/multicalendar/{self._listing_id}',
+    )
+    events = (
+      resp.get('data', {}).get('presentation', {})
+          .get('hostCalendarListingEvents', {}).get('events', [])
+    )
+    return [
+      CalendarEvent(
+        start_date=e['startDate'],
+        end_date=e['endDate'],
+        name=e['displayName'],
+        type=e['type'],
+        event_id=e['eventId'],
+      )
+      for e in events
+    ]

+ 689 - 0
room_cal/service_api/_booking.py

@@ -0,0 +1,689 @@
+import asyncio
+import json
+import logging
+import os
+import random
+import time
+import warnings
+from dataclasses import dataclass
+from room_cal._utils import local_today as _local_today
+from urllib.parse import parse_qs, urlparse
+
+import httpx
+
+from ._scripts import refresh_booking_cookies
+from room_cal._utils import data_home, get_config, read_env
+from ._utils import month_bounds, months_in_range
+from .credentials import is_stale, mark_stale, set_credentials
+from .types import DayInfo, ServiceAuthError, Reservation
+
+_log = logging.getLogger(__name__)
+
+
+def _is_past(d: str) -> bool:
+  return d < _local_today().isoformat()
+
+
+_BASE = 'https://admin.booking.com'
+_AUTH_HINT = 'refresh-creds'
+
+_UPDATE_MUTATION = """mutation updateRoomDateMutation($input: PartnerAvailabilityUpdateInput) {
+  partnerAvailabilityMutations {
+    partnerAvailabilityUpdate(input: $input) {
+      success
+      hotel {
+        rooms {
+          roomId
+          dates {
+            date
+            hidden
+            roomsToSell { changed value __typename }
+            status { changed value __typename }
+            statusReason { changed value __typename }
+            __typename
+          }
+          rates {
+            rateId
+            restrictionAdvice { date minStayThrough __typename }
+            dates {
+              date status statusReason
+              roomsToSell { changed value __typename }
+              priceSingleUse { changed value __typename }
+              price { occupancy value __typename }
+              minStayThrough { changed value __typename }
+              minStayArrival { changed value __typename }
+              minAdvanceRes { changed value __typename }
+              maxStayThrough { changed value __typename }
+              maxStayArrival { changed value __typename }
+              maxAdvanceRes { changed value __typename }
+              lengthOfStay { changed value __typename }
+              exactStayArrival { changed value __typename }
+              closedOnDeparture { changed value __typename }
+              closedOnArrival { changed value __typename }
+              closed
+              __typename
+            }
+            __typename
+          }
+          __typename
+        }
+        __typename
+      }
+      result {
+        warnings { message dates code __typename }
+        untilDate success fromDate
+        errors { dates message __typename }
+        __typename
+      }
+      __typename
+    }
+    __typename
+  }
+}"""
+
+_CONVERSATION_MESSAGES_QUERY = """query conversationMessages($input: ChatSingleConversationInput!) {
+  chatConversation {
+    singleConversation(input: $input) {
+      conversation {
+        flags { isReplyNeeded __typename }
+        __typename
+      }
+      messages {
+        messageId created content type
+        sender { participantType __typename }
+        attributes {
+          ... on SelfServiceEventDetailChatEntity {
+            eventDetail { body __typename }
+            __typename
+          }
+        }
+        __typename
+      }
+      __typename
+    }
+    __typename
+  }
+}"""
+
+_POST_FREE_TEXT_MUTATION = """mutation postFreeTextMessage($input: ChatPostFreeTextMessageInput!) {
+  chatMessage {
+    postFreeTextMessage(input: $input) {
+      message {
+        messageId created content type
+        sender { participantType __typename }
+        __typename
+      }
+      __typename
+    }
+    __typename
+  }
+}"""
+
+
+class BookingAuthError(ServiceAuthError):
+  pass
+
+
+@dataclass
+class BookingMessage:
+  id: str
+  created_at: str
+  body: str | None
+  sender: str            # 'GUEST' | 'PARTNER'
+  body_translated: str | None = None
+
+
+@dataclass
+class BookingThread:
+  reservation_id: str
+  messages: list[BookingMessage]
+  is_reply_needed: bool = False
+
+
+def _page_view_id() -> str:
+  return ''.join(random.choices('0123456789abcdef', k=16))
+
+
+class _BookingClient:
+
+  def __init__(
+    self,
+    cookies: str,
+    session: str,
+    hotel_id: int,
+    hotel_account_id: int,
+    room_id: int,
+    rate_id: str,
+    csrf_token: str | None = None,
+    lang: str = 'xu',
+  ):
+    self._cookies = cookies
+    self._session = session
+    self._hotel_id = hotel_id
+    self._hotel_account_id = hotel_account_id
+    self._room_id = room_id
+    self._rate_id = rate_id
+    self._csrf_token = csrf_token
+    self._lang = lang
+    self._cache: dict[str, DayInfo] = {}
+    self._fetched_months: set[tuple[int, int]] = set()
+    self._refresh_lock = asyncio.Lock()
+    self._refresh_task: asyncio.Task | None = None
+
+  @classmethod
+  def from_env(cls) -> '_BookingClient':
+    env_file = data_home() / 'booking.env'
+    env = {**read_env(env_file), **os.environ}
+    required_env = ['BOOKING_COOKIES', 'BOOKING_SESSION']
+    missing_env = [k for k in required_env if not env.get(k)]
+    if missing_env:
+      raise ValueError(f'Missing in {env_file}: {", ".join(missing_env)}')
+    svc = get_config().services['booking']
+    missing_ids = [k for k in ('hotel_id', 'hotel_account_id', 'room_id', 'rate_id') if not getattr(svc, k)]
+    if missing_ids:
+      raise ValueError(f'Missing from config.json booking: {", ".join(missing_ids)}')
+    return cls(
+      cookies=env['BOOKING_COOKIES'],
+      session=env['BOOKING_SESSION'],
+      hotel_id=int(svc.hotel_id),
+      hotel_account_id=int(svc.hotel_account_id),
+      room_id=int(svc.room_id),
+      rate_id=svc.rate_id,
+      csrf_token=env.get('BOOKING_CSRF_TOKEN'),
+    )
+
+  def clear_cache(self) -> None:
+    self._cache.clear()
+    self._fetched_months.clear()
+
+  def _reload_from_env(self) -> None:
+    env_file = data_home() / 'booking.env'
+    env = {**read_env(env_file), **os.environ}
+    self._cookies = env.get('BOOKING_COOKIES', self._cookies)
+    self._session = env.get('BOOKING_SESSION', self._session)
+    self._csrf_token = env.get('BOOKING_CSRF_TOKEN', self._csrf_token)
+    self.clear_cache()
+
+  def _update_csrf(self, headers: httpx.Headers) -> None:
+    """Capture CSRF from a response header if present and not yet known."""
+    csrf = headers.get('x-booking-csrf')
+    if csrf and csrf != 'undefined' and csrf != self._csrf_token:
+      self._csrf_token = csrf
+      set_credentials('booking', {'BOOKING_CSRF_TOKEN': csrf})
+
+  async def _try_http_session_refresh(self) -> bool:
+    """GET the monthly calendar page; extract a fresh ses= from the final URL.
+
+    Also captures CSRF from response headers if present.
+    Returns True if the session is still alive, False if re-authentication is needed.
+    """
+    url = f'{_BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/monthly.html'
+    params = {
+      'hotel_id': str(self._hotel_id),
+      'lang': self._lang,
+      'ses': self._session,
+      'view_mode': 'MONTHLY',
+    }
+    headers = {
+      'Accept': 'text/html,application/xhtml+xml,*/*',
+      'Accept-Language': 'en-US,en;q=0.9',
+      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
+      'Cookie': self._cookies,
+    }
+    try:
+      async with httpx.AsyncClient(follow_redirects=True, timeout=20) as client:
+        r = await client.get(url, params=params, headers=headers)
+      final_url = str(r.url)
+      if 'message=ERR100' in final_url or 'account.booking.com' in final_url:
+        return False
+      new_ses = parse_qs(urlparse(final_url).query).get('ses', [None])[0]
+      if new_ses:
+        self._update_csrf(r.headers)
+        if new_ses != self._session:
+          self._session = new_ses
+          set_credentials('booking', {'BOOKING_SESSION': new_ses})
+        self.clear_cache()
+        return True
+      return False
+    except Exception:
+      return False
+
+  async def _auto_refresh(self, *, trigger: str = 'unknown') -> None:
+    _log.warning('Booking.com session may be expired — trying HTTP session refresh')
+    if await self._try_http_session_refresh():
+      _log.info('Booking.com session refreshed via HTTP')
+      return
+    _log.warning('HTTP refresh failed — launching headless Playwright')
+    try:
+      refresh_booking_cookies()
+      self._reload_from_env()
+    except ServiceAuthError:
+      mark_stale('booking')
+      raise
+
+  async def _ensure_refreshed(self, *, trigger: str) -> None:
+    async with self._refresh_lock:
+      if self._refresh_task is None or self._refresh_task.done():
+        self._refresh_task = asyncio.create_task(self._auto_refresh(trigger=trigger))
+      task = self._refresh_task
+    await task
+
+  def _actor(self) -> dict:
+    return {'actorId': str(self._hotel_id), 'actorType': 'HOTEL'}
+
+  def _common_params(self, extra: dict | None = None) -> dict[str, str]:
+    p = {
+      'ses': self._session,
+      'hotel_id': str(self._hotel_id),
+      'lang': self._lang,
+      'hotel_account_id': str(self._hotel_account_id),
+    }
+    if extra:
+      p.update(extra)
+    return p
+
+  def _messaging_referer(self) -> str:
+    return (f'{_BASE}/hotel/hoteladmin/extranet_ng/manage/messaging/inbox.html'
+            f'?hotel_id={self._hotel_id}&lang={self._lang}&ses={self._session}&source=nav&tlc=1')
+
+  def _monthly_referer(self) -> str:
+    return (f'{_BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/monthly.html'
+            f'?hotel_id={self._hotel_id}&lang={self._lang}&ses={self._session}&view_mode=MONTHLY')
+
+  def _matrix_referer(self) -> str:
+    return (f'{_BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/index.html'
+            f'?view_mode=MATRIX&hotel_id={self._hotel_id}&lang={self._lang}&ses={self._session}')
+
+  def _rest_headers(self, referer: str, extra: dict | None = None) -> dict[str, str]:
+    h = {
+      'Accept': 'application/json, text/plain, */*',
+      'Accept-Language': 'en-US,en;q=0.9',
+      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
+      'Origin': _BASE,
+      'Referer': referer,
+      'Sec-Fetch-Mode': 'cors',
+      'Sec-Fetch-Dest': 'empty',
+      'Sec-Fetch-Site': 'same-origin',
+      'X-Booking-SiteType-Id': '31',
+      'X-Booking-Language-Code': 'en-us',
+      'X-Booking-Pageview-Id': _page_view_id(),
+      'Cookie': self._cookies,
+    }
+    if extra:
+      h.update(extra)
+    return h
+
+  def _gql_headers(self, referer: str) -> dict[str, str]:
+    return {
+      'Accept': '*/*',
+      'Accept-Language': 'en-US,en;q=0.9',
+      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
+      'Origin': _BASE,
+      'Referer': referer,
+      'Sec-Fetch-Mode': 'cors',
+      'Sec-Fetch-Dest': 'empty',
+      'Sec-Fetch-Site': 'same-origin',
+      'x-booking-site-type-id': '31',
+      'x-booking-context-action': 'hhemc_index',
+      'x-booking-context-action-name': 'hhemc_index',
+      'X-Booking-Pageview-ID': _page_view_id(),
+      'x-booking-csrf-token': 'undefined',
+      'apollographql-client-name': 'b-partner-availability-extranet-mfe',
+      'apollographql-client-version': 'EQNeDROb',
+      'x-envoy-expected-rq-timeout-ms': '60000',
+      'x-booking-timeout-budget-ms': '60000',
+      'X-Booking-Topic': 'capla_browser_b-partner-availability-extranet-mfe',
+      'Cookie': self._cookies,
+    }
+
+  def _msg_headers(self) -> dict[str, str]:
+    return {
+      'Accept': '*/*',
+      'Accept-Language': 'en-US,en;q=0.9',
+      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
+      'Origin': _BASE,
+      'Referer': self._messaging_referer(),
+      'Sec-Fetch-Mode': 'cors',
+      'Sec-Fetch-Dest': 'empty',
+      'Sec-Fetch-Site': 'same-origin',
+      'x-booking-site-type-id': '31',
+      'x-booking-context-action': 'hhemm_inbox',
+      'x-booking-context-action-name': 'hhemm_inbox',
+      'X-Booking-Pageview-ID': _page_view_id(),
+      'x-booking-csrf-token': 'undefined',
+      'X-Booking-Topic': 'capla_browser_b-partner-messaging-extranet-mfe',
+      'apollographql-client-name': 'b-partner-messaging-extranet-mfe',
+      'apollographql-client-version': 'ABJOTMEQ',
+      'Cookie': self._cookies,
+    }
+
+  async def _msg_gql(self, operation_name: str, query: str, variables: dict) -> dict:
+    params = {
+      'hotel_id': str(self._hotel_id),
+      'lang': self._lang,
+      'ses': self._session,
+      'source': 'nav',
+      'tlc': '1',
+    }
+    async def _attempt() -> httpx.Response:
+      async with httpx.AsyncClient() as client:
+        return await client.post(
+          f'{_BASE}/dml/graphql.json',
+          params=params,
+          json={'operationName': operation_name, 'variables': variables, 'query': query},
+          headers=self._msg_headers(),
+        )
+    try:
+      r = await _attempt()
+    except (httpx.TransportError, OSError, ExceptionGroup) as e:
+      raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
+    if r.status_code in (400, 401, 403):
+      await self._ensure_refreshed(trigger='messaging')
+      r = await _attempt()
+    await self._check_auth(r)
+    r.raise_for_status()
+    raw = r.json()
+    if raw.get('errors'):
+      msgs = ', '.join(e.get('message', '') for e in raw['errors'])
+      raise RuntimeError(f'GraphQL error: {msgs}')
+    return raw
+
+  async def _check_auth(self, r: httpx.Response) -> None:
+    if r.status_code in (400, 401, 403):
+      mark_stale('booking')
+      raise BookingAuthError(f'Booking.com session expired (HTTP {r.status_code}) — run: {_AUTH_HINT}')
+
+  async def _multipart_post(self, url: str, referer: str, body: dict, extra_headers: dict | None = None) -> dict:
+    async def _attempt() -> httpx.Response:
+      headers = self._rest_headers(referer, extra_headers)
+      async with httpx.AsyncClient() as client:
+        return await client.post(url, params=self._common_params(),
+                                 files={'request': (None, json.dumps(body))}, headers=headers)
+    try:
+      r = await _attempt()
+    except (httpx.TransportError, OSError, ExceptionGroup) as e:
+      raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
+    if r.status_code in (400, 401, 403):
+      await self._ensure_refreshed(trigger='inventory/fetch')
+      r = await _attempt()
+    await self._check_auth(r)
+    r.raise_for_status()
+    self._update_csrf(r.headers)
+    return r.json()
+
+  async def _gql_mutate(self, variables: dict) -> dict:
+    async def _attempt() -> httpx.Response:
+      referer = self._matrix_referer()
+      params = {'view_mode': 'MATRIX', 'hotel_id': str(self._hotel_id),
+                'lang': self._lang, 'ses': self._session}
+      async with httpx.AsyncClient() as client:
+        return await client.post(
+          f'{_BASE}/dml/graphql.json',
+          params=params,
+          json={'operationName': 'updateRoomDateMutation', 'variables': variables,
+                'extensions': {}, 'query': _UPDATE_MUTATION},
+          headers=self._gql_headers(referer),
+        )
+    try:
+      r = await _attempt()
+    except (httpx.TransportError, OSError, ExceptionGroup) as e:
+      raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
+    if r.status_code in (400, 401, 403):
+      await self._ensure_refreshed(trigger='graphql')
+      r = await _attempt()
+    await self._check_auth(r)
+    r.raise_for_status()
+    raw = r.json()
+    if raw.get('errors'):
+      msgs = ', '.join(e.get('message', '') for e in raw['errors'])
+      raise RuntimeError(f'GraphQL error: {msgs}')
+    result = (raw.get('data', {}).get('partnerAvailabilityMutations', {})
+                  .get('partnerAvailabilityUpdate', {}))
+    if not result.get('success'):
+      errs = ', '.join(e.get('message', '') for e in (result.get('result', {}).get('errors') or []))
+      raise RuntimeError(f'updateRoomDateMutation failed{": " + errs if errs else ""}')
+    return raw
+
+  def _parse_calendar_days(self, raw: dict) -> dict[str, DayInfo]:
+    hotel_data = raw.get('data', {}).get('hotel', {}).get(str(self._hotel_id), {})
+    room_data = hotel_data.get('rooms', {}).get(str(self._room_id), {})
+    rate_dates = room_data.get('rates', {}).get(str(self._rate_id), {}).get('dates', {})
+    result: dict[str, DayInfo] = {}
+    for d, info in room_data.get('dates', {}).items():
+      price_str = rate_dates.get(d, {}).get('price', {}).get('2')
+      result[d] = DayInfo(
+        date=d,
+        price=round(float(price_str)) if price_str else None,
+        available=(info.get('hidden', 0) == 0),
+        booked=(info.get('net_booked', 0) > 0),
+        rooms_to_sell=info.get('rooms_to_sell', 0),
+      )
+    return result
+
+  async def _fetch_month_calendar(self, month: int, year: int) -> dict[str, DayInfo]:
+    start, end = month_bounds(month, year)
+    if _is_past(end):
+      warnings.warn(f'Booking.com: skipping calendar fetch for past month {year}-{month:02d}', stacklevel=3)
+      return {}
+    body = {
+      'dates': {'range': True, 'dates': [start, end]},
+      'hotel': {
+        'fields': ['rooms', 'policygroups'],
+        'rooms': {
+          'id': [self._room_id],
+          'fields': ['status', 'rooms_to_sell', 'rates', 'nr_rooms', 'net_booked', 'num_guests'],
+          'rates': {'fields': ['price', 'status', 'rooms_to_sell', 'name']},
+        },
+        'policy': {'fields': ['info']},
+      },
+      'post_process_data_plugins': ['format_price', 'multi_units_check', 'last_update'],
+    }
+    raw = await self._multipart_post(
+      f'{_BASE}/fresa/extranet/inventory/fetch', self._monthly_referer(), body
+    )
+    return self._parse_calendar_days(raw)
+
+  async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
+    if is_stale('booking'):
+      raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
+    for month, year in months_in_range(date_from, date_to):
+      if (month, year) not in self._fetched_months:
+        self._cache.update(await self._fetch_month_calendar(month, year))
+        self._fetched_months.add((month, year))
+    return {d: info for d, info in self._cache.items() if date_from <= d <= date_to}
+
+  async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
+    if is_stale('booking'):
+      raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
+    async def _attempt() -> httpx.Response:
+      referer = self._monthly_referer()
+      params = self._common_params({
+        'from': date_from,
+        'to': date_to,
+        'room_id': str(self._room_id),
+        'only_basic_info': '1',
+      })
+      async with httpx.AsyncClient() as client:
+        return await client.post(
+          f'{_BASE}/fresa/extranet/calendar/get_reservations',
+          params=params,
+          headers=self._rest_headers(referer),
+        )
+    try:
+      r = await _attempt()
+    except (httpx.TransportError, OSError, ExceptionGroup) as e:
+      raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
+    if r.status_code in (400, 401, 403):
+      await self._ensure_refreshed(trigger='get_reservations')
+      r = await _attempt()
+    await self._check_auth(r)
+    r.raise_for_status()
+    raw = r.json()
+
+    reservations = raw.get('data', {}).get('reservations') or {}
+    return [
+      Reservation(
+        service='booking',
+        reservation_id=str(rid),
+        check_in=r_data.get('checkin', ''),
+        check_out=r_data.get('checkout', ''),
+        guest_name=(r_data.get('show_name')
+                    or (f"{r_data['firstname']} {r_data['lastname']}"
+                        if r_data.get('firstname') and r_data.get('lastname')
+                        else None)),
+        chat_id=str(rid),
+      )
+      for rid, r_data in reservations.items()
+    ]
+
+  async def get_thread(self, reservation_id: str) -> BookingThread:
+    """Fetch a conversation thread and its messages by reservation ID."""
+    if is_stale('booking'):
+      raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
+    raw = await self._msg_gql(
+      'conversationMessages', _CONVERSATION_MESSAGES_QUERY,
+      {'input': {
+        'actor': self._actor(),
+        'conversationReference': {
+          'productId': reservation_id,
+          'productType': 'POST_BOOKING',
+          'verticalType': 'ACCOMMODATION',
+        },
+        'pagination': {'count': 50},
+      }},
+    )
+    data = raw.get('data', {}).get('chatConversation', {}).get('singleConversation', {})
+    is_reply_needed = ((data.get('conversation') or {}).get('flags') or {}).get('isReplyNeeded', False)
+    messages = []
+    for m in data.get('messages', []):
+      msg_type = m.get('type')
+      content = m.get('content')
+      attrs = m.get('attributes') or {}
+      event_body = (attrs.get('eventDetail') or {}).get('body')
+      if msg_type == 'FREE_TEXT':
+        body, body_translated = content, None
+      elif event_body is not None:
+        body, body_translated = content, event_body
+      else:
+        continue  # system message with no displayable text
+      messages.append(BookingMessage(
+        id=m['messageId'],
+        created_at=m['created'],
+        body=body,
+        sender=(m.get('sender') or {}).get('participantType', ''),
+        body_translated=body_translated,
+      ))
+    return BookingThread(reservation_id=reservation_id, messages=messages, is_reply_needed=is_reply_needed)
+
+  async def send_message(self, reservation_id: str, body: str) -> None:
+    """Send a text message to the guest conversation for a reservation."""
+    if is_stale('booking'):
+      raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
+    idempotency = f'{reservation_id}-{int(time.time() * 1000)}'
+    await self._msg_gql(
+      'postFreeTextMessage', _POST_FREE_TEXT_MUTATION,
+      {'input': {
+        'actor': self._actor(),
+        'conversationReference': {
+          'productId': reservation_id,
+          'productType': 'POST_BOOKING',
+          'verticalType': 'ACCOMMODATION',
+        },
+        'idempotency': idempotency,
+        'content': body,
+        'attachmentIds': [],
+      }},
+    )
+
+  async def set_price(self, d: str, price: int, occupancy: int = 2) -> None:
+    if is_stale('booking'):
+      raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
+    if _is_past(d):
+      warnings.warn(f'Booking.com: skipping set_price for past date {d}', stacklevel=2)
+      return
+    if (cached := self._cache.get(d)) and cached.price == price:
+      return
+    if not self._csrf_token:
+      raise ValueError('BOOKING_CSRF_TOKEN not available — call get_calendar() first, or run refresh-creds')
+    price_str = str(price)
+    body = {
+      'update': [{
+        'room_id': self._room_id,
+        'from_date': d,
+        'until_date': d,
+        'limit_to_weekdays': [0, 1, 2, 3, 4, 5, 6],
+        'rate_id': self._rate_id,
+        'field_name': 'price',
+        'field_value': price_str,
+        'occupancy': occupancy,
+      }],
+      'modified_fields': {'price_changed': 1, 'price_changed_value': price_str,
+                          'date_selection_mode': 'CELLS'},
+      'post_process_data_plugins': ['format_price', 'last_update'],
+      'update_source': {'product': 'calendar', 'extra_data': {'new_calendar': 1, 'location': 'input'}},
+      'status_reason': 1,
+      'prevent_acav': '1',
+      'check_adding_av_to_closed_property': '0',
+    }
+    await self._multipart_post(
+      f'{_BASE}/fresa/extranet/inventory/update', self._monthly_referer(), body,
+      extra_headers={'X-Booking-CSRF': self._csrf_token},
+    )
+    self.clear_cache()
+    _log.info('booking set_price date=%s price=$%d', d, price)
+
+  async def set_availability(self, d: str, available: bool) -> None:
+    if is_stale('booking'):
+      raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
+    if _is_past(d):
+      warnings.warn(f'Booking.com: skipping set_availability for past date {d}', stacklevel=2)
+      return
+    expected_rts = 1 if available else 0
+    if (cached := self._cache.get(d)) and cached.available == available and cached.rooms_to_sell == expected_rts:
+      return
+    await self._gql_mutate({'input': {
+      'checkAddingAVToClosedProperty': False,
+      'hotelId': self._hotel_id,
+      'postProcessDataPlugins': ['mlos_status_reason'],
+      'preventAcav': True,
+      'statusReason': True,
+      'updateAvailability': [{
+        'fromDate': d,
+        'roomId': self._room_id,
+        'untilDate': d,
+        'hidden': not available,
+        'additionalChecks': {'alertMode': 'WARN'},
+      }],
+    }})
+    if not available:
+      await self.set_rooms_to_sell(d, 0)
+    else:
+      cal = await self.get_calendar(d, d)
+      if not cal.get(d, DayInfo(date=d, price=None, available=True, booked=False, rooms_to_sell=None)).booked:
+        await self.set_rooms_to_sell(d, 1)
+    self.clear_cache()
+    _log.info('booking set_availability date=%s available=%s', d, available)
+
+  async def set_rooms_to_sell(self, d: str, count: int) -> None:
+    if is_stale('booking'):
+      raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
+    if _is_past(d):
+      warnings.warn(f'Booking.com: skipping set_rooms_to_sell for past date {d}', stacklevel=2)
+      return
+    entry: dict = {
+      'fromDate': d,
+      'roomId': self._room_id,
+      'untilDate': d,
+      'roomsToSell': count,
+    }
+    if count > 0:
+      entry['additionalChecks'] = {'alertMode': 'WARN'}
+    await self._gql_mutate({'input': {
+      'checkAddingAVToClosedProperty': False,
+      'hotelId': self._hotel_id,
+      'postProcessDataPlugins': ['mlos_status_reason'],
+      'preventAcav': True,
+      'statusReason': True,
+      'updateAvailability': [entry],
+    }})
+    self.clear_cache()

+ 18 - 0
room_cal/service_api/_protocols.py

@@ -0,0 +1,18 @@
+from typing import Any, Protocol, runtime_checkable
+
+from .types import DayInfo, Reservation
+
+
+@runtime_checkable
+class ServiceClient(Protocol):
+  async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]: ...
+  async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]: ...
+  async def set_price(self, d: str, price: int) -> None: ...
+  async def set_availability(self, d: str, available: bool) -> None: ...
+
+
+@runtime_checkable
+class ChatClient(ServiceClient, Protocol):
+  # chat_id maps to each platform's native thread/conversation identifier
+  async def get_thread(self, chat_id: str) -> Any: ...
+  async def send_message(self, chat_id: str, body: str) -> None: ...

+ 64 - 0
room_cal/service_api/_scripts.py

@@ -0,0 +1,64 @@
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from room_cal._utils import get_config
+
+
+def _data_home() -> Path:
+  override = os.environ.get('ROOM_CAL_DATA_HOME')
+  return Path(override) if override else Path.home() / '.local' / 'room_cal'
+
+
+def _ts_dir() -> Path:
+  return Path(__file__).parent / 'typescript'
+
+
+def _npm_env() -> dict:
+  dh = _data_home()
+  env = os.environ.copy()
+  env.setdefault('ROOM_CAL_DATA_HOME', str(dh))
+  env.setdefault('PLAYWRIGHT_BROWSERS_PATH', str(dh / '.playwright-browsers'))
+  api_url = get_config().api_url
+  if api_url:
+    env.setdefault('ROOM_CAL_API_URL', api_url)
+  return env
+
+
+def _ensure_installed(ts_dir: Path) -> None:
+  if not (ts_dir / 'node_modules').exists():
+    print('=== npm install (service-api) ===')
+    subprocess.run(['npm', 'install'], cwd=ts_dir, check=True)
+    print()
+
+
+def refresh_booking_cookies() -> None:
+  ts_dir = _ts_dir()
+  _ensure_installed(ts_dir)
+  env = _npm_env()
+  env['HEADLESS'] = '1'
+  try:
+    subprocess.run(['npm', 'run', 'refresh-booking'], cwd=ts_dir, env=env, check=True)
+  except subprocess.CalledProcessError as e:
+    from .types import ServiceAuthError
+    raise ServiceAuthError(f'Booking.com session expired — re-run without HEADLESS to re-authenticate (exit {e.returncode})') from None
+
+
+def refresh_airbnb_cookies() -> None:
+  ts_dir = _ts_dir()
+  _ensure_installed(ts_dir)
+  env = _npm_env()
+  env['HEADLESS'] = '1'
+  try:
+    subprocess.run(['npm', 'run', 'refresh-airbnb'], cwd=ts_dir, env=env, check=True)
+  except subprocess.CalledProcessError as e:
+    from .types import ServiceAuthError
+    raise ServiceAuthError(f'Airbnb session expired — re-run without HEADLESS to re-authenticate (exit {e.returncode})') from None
+
+
+def refresh_cookies() -> None:
+  ts_dir = _ts_dir()
+  _ensure_installed(ts_dir)
+  result = subprocess.run(['npm', 'run', 'refresh-all', '--'] + sys.argv[1:], cwd=ts_dir, env=_npm_env())
+  sys.exit(result.returncode)

+ 18 - 0
room_cal/service_api/_utils.py

@@ -0,0 +1,18 @@
+from datetime import date, timedelta
+
+
+def month_bounds(month: int, year: int) -> tuple[str, str]:
+  start = date(year, month, 1)
+  end = date(year + month // 12, month % 12 + 1, 1) - timedelta(days=1)
+  return start.isoformat(), end.isoformat()
+
+
+def months_in_range(date_from: str, date_to: str) -> list[tuple[int, int]]:
+  start = date.fromisoformat(date_from)
+  end = date.fromisoformat(date_to)
+  months: list[tuple[int, int]] = []
+  cur = date(start.year, start.month, 1)
+  while cur <= end:
+    months.append((cur.month, cur.year))
+    cur = date(cur.year + cur.month // 12, cur.month % 12 + 1, 1)
+  return months

+ 150 - 0
room_cal/service_api/client.py

@@ -0,0 +1,150 @@
+import asyncio
+from datetime import date
+
+from ._airbnb import _AirbnbClient
+from ._booking import _BookingClient
+from ._protocols import ChatClient, ServiceClient
+from .types import DayInfo
+
+
+class ServiceAPI:
+  """Unified async client for all booking platform services.
+
+  Credentials are read from <service>.env files in the data directory
+  (~/.local/room_cal/ or ROOM_CAL_DATA_HOME).
+  All date arguments are native datetime.date objects.
+
+  Pass a custom `clients` dict to substitute stubs for testing.
+  """
+
+  def __init__(self, clients: dict[str, ServiceClient] | None = None) -> None:
+    self._clients: dict[str, ServiceClient] = clients if clients is not None else {
+      'airbnb': _AirbnbClient.from_env(),
+      'booking': _BookingClient.from_env(),
+    }
+
+  def client(self, key: str) -> ServiceClient:
+    """Return the platform client for `key`, typed as ServiceClient."""
+    try:
+      return self._clients[key]
+    except KeyError:
+      raise KeyError(f'unknown service: {key!r}') from None
+
+  def chat_client(self, key: str) -> ChatClient:
+    """Return the platform client for `key` if it supports chat; raise TypeError otherwise."""
+    c = self.client(key)
+    if not isinstance(c, ChatClient):
+      raise TypeError(f'{key!r} does not support chat')
+    return c  # type: ignore[return-value]
+
+  async def get_prices_range(self, start_date: date, end_date: date) -> dict[str, dict[str, int | None]]:
+    """Return {date_str: {service_key: price}} for all dates in the range.
+
+    Uses each client's bulk get_calendar call so the whole range is fetched
+    in one pass (month-by-month internally). Price is None for booked or
+    unavailable dates where the platform does not report a price.
+    """
+    ds_from = start_date.isoformat()
+    ds_to = end_date.isoformat()
+    keys = list(self._clients.keys())
+    cals = await asyncio.gather(*[
+      client.get_calendar(ds_from, ds_to) for client in self._clients.values()
+    ])
+    all_dates: set[str] = set()
+    for cal in cals:
+      all_dates.update(d for d in cal if ds_from <= d <= ds_to)
+    result: dict[str, dict[str, int | None]] = {}
+    for d in sorted(all_dates):
+      result[d] = {key: cal[d].price if d in cal else None for key, cal in zip(keys, cals)}
+    return result
+
+  async def get_prices(self, d: date) -> dict[str, int | None]:
+    """Return {service_key: price} for all services on a single date."""
+    ds = d.isoformat()
+    keys = list(self._clients.keys())
+    cals = await asyncio.gather(*[
+      client.get_calendar(ds, ds) for client in self._clients.values()
+    ])
+    return {key: cal[ds].price if ds in cal else None for key, cal in zip(keys, cals)}
+
+  async def set_prices(self, d: date, prices: dict[str, int]) -> None:
+    """Set price on each specified service; skips booked dates."""
+    ds = d.isoformat()
+    _fallback = DayInfo(date=ds, price=None, available=True, booked=False, rooms_to_sell=None)
+    cals = await asyncio.gather(*[self._clients[key].get_calendar(ds, ds) for key in prices])
+    to_set = {
+      key: price
+      for (key, price), cal in zip(prices.items(), cals)
+      if not cal.get(ds, _fallback).booked
+    }
+    if to_set:
+      await asyncio.gather(*[self._clients[key].set_price(ds, price) for key, price in to_set.items()])
+
+  async def get_available(self, d: date) -> bool:
+    """Return True only when all services have the date open."""
+    ds = d.isoformat()
+    keys = list(self._clients.keys())
+    cals = await asyncio.gather(*[
+      client.get_calendar(ds, ds) for client in self._clients.values()
+    ])
+    _closed = DayInfo(date=ds, price=None, available=False, booked=False, rooms_to_sell=None)
+    return all(cal.get(ds, _closed).available for cal in cals)
+
+  async def set_available(self, d: date, available: bool) -> None:
+    """Open or block a date on all services; skips booked dates."""
+    ds = d.isoformat()
+    _fallback = DayInfo(date=ds, price=None, available=True, booked=False, rooms_to_sell=None)
+    keys = list(self._clients.keys())
+    cals = await asyncio.gather(*[client.get_calendar(ds, ds) for client in self._clients.values()])
+    to_set = [
+      client for key, client, cal in zip(keys, self._clients.values(), cals)
+      if not cal.get(ds, _fallback).booked
+    ]
+    if to_set:
+      await asyncio.gather(*[client.set_availability(ds, available) for client in to_set])
+
+  async def get_closed_by_date(self, start_date: date, end_date: date) -> dict[str, list[str]]:
+    """Return {date_str: [service_keys]} for dates blocked (not available, not booked) on at least one service."""
+    ds_from = start_date.isoformat()
+    ds_to = end_date.isoformat()
+    keys = list(self._clients.keys())
+    cals = await asyncio.gather(*[
+      client.get_calendar(ds_from, ds_to) for client in self._clients.values()
+    ])
+    all_dates: set[str] = set()
+    for cal in cals:
+      all_dates.update(cal.keys())
+    result: dict[str, list[str]] = {}
+    for d in sorted(all_dates):
+      if not (ds_from <= d <= ds_to):
+        continue
+      closed = [key for key, cal in zip(keys, cals) if (day := cal.get(d)) and not day.available and not day.booked]
+      if closed:
+        result[d] = closed
+    return result
+
+  async def get_reservations(self, start_date: date, end_date: date) -> list[dict]:
+    """Return reservations from all services, sorted by start_date.
+
+    Each entry: {service, start_date, end_date, id, guest_name}
+    """
+    keys = list(self._clients.keys())
+    all_res = await asyncio.gather(*[
+      client.get_reservations(start_date.isoformat(), end_date.isoformat())
+      for client in self._clients.values()
+    ])
+    result: list[dict] = []
+    for key, reservations in zip(keys, all_res):
+      for r in reservations:
+        result.append({
+          'service': key,
+          'start_date': date.fromisoformat(r.check_in),
+          'end_date': date.fromisoformat(r.check_out),
+          'id': r.reservation_id,
+          'guest_name': r.guest_name,
+          'service_data': r.service_data or {},
+          'chat_id': r.chat_id,
+          'door_code': r.door_code,
+        })
+    result.sort(key=lambda x: x['start_date'])
+    return result

+ 74 - 0
room_cal/service_api/credentials.py

@@ -0,0 +1,74 @@
+import logging
+import re
+from datetime import datetime, timezone
+from pathlib import Path
+
+from room_cal._utils import data_home
+
+_log = logging.getLogger(__name__)
+_stale: set[str] = set()
+
+
+def mark_stale(service_key: str) -> None:
+  _stale.add(service_key)
+  _log.warning('creds stale: %s — run refresh-creds', service_key)
+
+
+def clear_stale(service_key: str) -> None:
+  _stale.discard(service_key)
+
+
+def is_stale(service_key: str) -> bool:
+  return service_key in _stale
+
+
+def _env_path(service_key: str) -> Path:
+  return data_home() / f'{service_key}.env'
+
+
+def _upsert(text: str, key: str, value: str) -> str:
+  line = f'{key}={value}'
+  if re.search(rf'^{re.escape(key)}=', text, re.MULTILINE):
+    return re.sub(rf'^{re.escape(key)}=.*', line, text, flags=re.MULTILINE)
+  stripped = text.rstrip('\n')
+  return (stripped + '\n' + line + '\n') if stripped else (line + '\n')
+
+
+def set_credentials(service_key: str, credentials: dict[str, str]) -> None:
+  """Upsert credentials into <service>.env and record a refresh timestamp."""
+  path = _env_path(service_key)
+  path.parent.mkdir(parents=True, exist_ok=True)
+  text = path.read_text() if path.exists() else ''
+  for key, value in credentials.items():
+    text = _upsert(text, key, value)
+  ts_key = f'{service_key.upper()}_UPDATED_AT'
+  text = _upsert(text, ts_key, datetime.now(timezone.utc).isoformat())
+  path.write_text(text)
+  clear_stale(service_key)
+  _log.info('creds updated: %s', service_key)
+
+
+def check_credentials(service_key: str) -> dict:
+  """Return freshness info for a service's credentials without making a network call.
+
+  Returns {'valid': bool, 'age_days': int|None, 'reason': str}.
+  valid=True only means credentials are present and have a known age; it does not
+  guarantee the session is still accepted by the platform.
+  """
+  path = _env_path(service_key)
+  if not path.exists():
+    return {'valid': False, 'age_days': None, 'reason': f'{path.name} not found'}
+
+  text = path.read_text()
+  ts_key = f'{service_key.upper()}_UPDATED_AT'
+  m = re.search(rf'^{ts_key}=(.+)$', text, re.MULTILINE)
+  if not m:
+    return {'valid': True, 'age_days': None, 'reason': 'credentials present but no refresh timestamp'}
+
+  try:
+    updated = datetime.fromisoformat(m.group(1).strip())
+    age_days = (datetime.now(timezone.utc) - updated).days
+  except ValueError:
+    return {'valid': True, 'age_days': None, 'reason': 'unreadable refresh timestamp'}
+
+  return {'valid': True, 'age_days': age_days, 'reason': f'last refreshed {age_days}d ago'}

+ 36 - 0
room_cal/service_api/types.py

@@ -0,0 +1,36 @@
+from dataclasses import dataclass
+
+
+class ServiceAuthError(Exception):
+  """Raised when a platform session expires. Refresh cookies and retry."""
+
+
+@dataclass
+class DayInfo:
+  date: str            # YYYY-MM-DD
+  price: int | None
+  available: bool
+  booked: bool
+  rooms_to_sell: int | None  # Booking.com only; None for Airbnb
+
+
+@dataclass
+class Reservation:
+  service: str
+  reservation_id: str
+  check_in: str        # YYYY-MM-DD
+  check_out: str       # YYYY-MM-DD
+  guest_name: str | None
+  door_code: str | None = None   # last 4 digits of guest's phone (Airbnb suggested door code)
+  thread_id: str | None = None   # platform messaging thread ID (numeric string for Airbnb)
+  service_data: dict[str, str] | None = None  # arbitrary {str: str} extras from the service API
+  chat_id: str | None = None     # ID to pass to get_thread() for this reservation
+
+
+@dataclass
+class CalendarEvent:
+  start_date: str   # YYYY-MM-DD
+  end_date: str     # YYYY-MM-DD
+  name: str
+  type: str         # 'EVENT' | 'HOLIDAY'
+  event_id: str

+ 61 - 0
room_cal/service_api/typescript/airbnb/refresh-cookies.ts

@@ -0,0 +1,61 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { filterCookies, parseOutputMode } from '../shared/cookie-utils';
+import { handleOutput } from '../shared/api-client';
+import { openPersistentContext, profileDir } from '../shared/browser';
+import type { CredentialsPayload } from '../shared/types';
+
+const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+const ENV_PATH = path.join(DATA_HOME, 'airbnb.env');
+const STRIP = new Set(['muxData', '_cci', '_ccv', 'previousTab', 'tzo', 'cbkp']);
+const FORCE_HEADLESS = !!process.env.HEADLESS;
+
+export async function refresh(mode: string): Promise<CredentialsPayload> {
+  const profile = profileDir('airbnb', mode);
+  fs.mkdirSync(profile, { recursive: true });
+
+  console.error('Trying silent refresh (headless)...');
+  let context = await openPersistentContext(profile, true);
+  let page = context.pages()[0] ?? await context.newPage();
+
+  await page.goto('https://www.airbnb.com/multicalendar');
+  await page.waitForLoadState('domcontentloaded');
+
+  if (!page.url().includes('/multicalendar')) {
+    if (FORCE_HEADLESS) {
+      await context.close();
+      throw new Error('Not logged in — re-run without HEADLESS=1 to authenticate.');
+    }
+    console.error('Session expired — switching to headed browser for login...');
+    await context.close();
+    context = await openPersistentContext(profile, false);
+    page = context.pages()[0] ?? await context.newPage();
+    console.error('Not logged in — log in then navigate to your calendar (5 min timeout)...');
+    await page.goto('https://www.airbnb.com/multicalendar');
+    await page.waitForLoadState('domcontentloaded');
+    await page.waitForURL('**/multicalendar**', { timeout: 300_000 });
+  } else {
+    console.error('Session valid — continuing silently.');
+  }
+
+  console.error('Capturing cookies...');
+  const all = await context.cookies();
+  await context.close();
+
+  const cookieString = filterCookies(all, STRIP, 'airbnb.com');
+  if (!cookieString) throw new Error('No Airbnb cookies found — are you logged in?');
+
+  console.error(`Captured ${all.filter(c => c.domain.includes('airbnb.com') && !STRIP.has(c.name)).length} cookies.`);
+  return { AIRBNB_COOKIES: cookieString };
+}
+
+async function main(): Promise<void> {
+  const mode = parseOutputMode(process.argv);
+  const credentials = await refresh(mode);
+  await handleOutput('airbnb', credentials, mode, ENV_PATH);
+}
+
+if (require.main === module) {
+  main().catch(err => { console.error(err.message); process.exit(1); });
+}

+ 285 - 0
room_cal/service_api/typescript/airbnb/src/client.ts

@@ -0,0 +1,285 @@
+import type {
+  AirbnbClientConfig,
+  AvailabilityAction,
+  CalendarResponse,
+  DateDetailsResponse,
+  DateRange,
+  DayPriceInfo,
+  ReservationDetails,
+  StayHostingDetailsResponse,
+} from './types';
+
+const BASE_URL = 'https://www.airbnb.com/api/v3';
+
+export class AirbnbAuthError extends Error {
+  constructor() {
+    super('Airbnb session expired — run: npm run refresh-cookies');
+    this.name = 'AirbnbAuthError';
+  }
+}
+const API_KEY = 'd306zoyjsyarp7ifhu67rjxn52tv0t20';
+const CLIENT_VERSION = '47f21b538896b7b08ffa1b930fc61ac0a2074745';
+
+// SHA-256 hashes for GraphQL persisted queries — stable identifiers tied to
+// specific query versions on Airbnb's servers.
+const HASHES = {
+  getDLSHostCalendar:               'a718eeddcd016962c03d5b5ca6e7e257f0b71590c8ec0834839b61c70bcf367b',
+  EditPanelQuery:                   '30f326aeed71d525df275356a61f1bf604f3b0129731589f52c5a2682ef8e1de',
+  EditPanelPricingSettingsMutation: 'e27fed4f08dd4ab4c0267c5d6c0a7e4920730deadf48f92feba9b0d08befafa4',
+  EditPanelAvailabilityMutation:    'b855fc706de32097489d2ea83a113476a273acdff59aad682c837075af45dcc8',
+  StayHostingDetailsQuery:          '722b9b8e317107443e8774a90acbf9ff873021ee4044322f3e0898cd080ab1dc',
+} as const;
+
+// getDLSHostCalendar uses a base64-encoded "StayListing:{id}" global ID.
+// All other operations use the plain numeric listing ID.
+function encodeListingId(numericId: string): string {
+  return Buffer.from(`StayListing:${numericId}`).toString('base64');
+}
+
+function traceId(): string {
+  const chars = '0123456789abcdefghijklmnopqrstuvwxyz';
+  return Array.from({ length: 28 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
+}
+
+function addDays(date: string, days: number): string {
+  const [y, m, d] = date.split('-').map(Number);
+  const result = new Date(y, m - 1, d + days);
+  return [
+    result.getFullYear(),
+    String(result.getMonth() + 1).padStart(2, '0'),
+    String(result.getDate()).padStart(2, '0'),
+  ].join('-');
+}
+
+function monthBounds(month: number, year: number): { startDate: string; endDate: string } {
+  const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
+  const lastDay = new Date(year, month, 0).getDate();
+  const endDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
+  return { startDate, endDate };
+}
+
+export class AirbnbClient {
+  private readonly cookies: string;
+  private readonly listingId: string;
+  private readonly globalListingId: string;
+  private readonly timeZone: string;
+
+  constructor(config: AirbnbClientConfig) {
+    this.cookies = config.cookies;
+    this.listingId = config.listingId;
+    this.globalListingId = encodeListingId(config.listingId);
+    this.timeZone = config.timeZone ?? 'America/Los_Angeles';
+  }
+
+  private headers(extra?: Record<string, string>): Record<string, string> {
+    return {
+      Cookie: this.cookies,
+      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
+      Accept: '*/*',
+      'Accept-Language': 'en-US,en;q=0.9',
+      'Content-Type': 'application/json',
+      'Sec-Fetch-Mode': 'cors',
+      'Sec-Fetch-Dest': 'empty',
+      'Sec-Fetch-Site': 'same-origin',
+      'X-Airbnb-API-Key': API_KEY,
+      'X-Airbnb-GraphQL-Platform': 'web',
+      'X-Airbnb-GraphQL-Platform-Client': 'minimalist-niobe',
+      'X-Airbnb-Supports-Airlock-V2': 'true',
+      'X-Niobe-Short-Circuited': 'true',
+      'X-Client-Version': CLIENT_VERSION,
+      'X-CSRF-Token': '',
+      'X-CSRF-Without-Token': '1',
+      'x-airbnb-client-trace-id': traceId(),
+      'x-client-request-id': traceId(),
+      'x-airbnb-network-log-link': traceId(),
+      ...extra,
+    };
+  }
+
+  private async gql<T>(
+    operationName: string,
+    hash: string,
+    variables: object,
+    referer: string,
+  ): Promise<T> {
+    const url = new URL(`${BASE_URL}/${operationName}/${hash}`);
+    url.searchParams.set('operationName', operationName);
+    url.searchParams.set('locale', 'en');
+    url.searchParams.set('currency', 'USD');
+    url.searchParams.set('variables', JSON.stringify(variables));
+    url.searchParams.set('extensions', JSON.stringify({
+      persistedQuery: { version: 1, sha256Hash: hash },
+    }));
+
+    const res = await fetch(url.toString(), {
+      method: 'GET',
+      headers: this.headers({ Referer: referer }),
+    });
+    if (res.status === 401) throw new AirbnbAuthError();
+    if (!res.ok) throw new Error(`${operationName} failed: ${res.status}\n${await res.text()}`);
+    const body = await res.json() as { errors?: Array<{ extensions?: { response?: { status?: number } } }> };
+    if (body.errors?.some(e => e.extensions?.response?.status === 401)) throw new AirbnbAuthError();
+    return body as T;
+  }
+
+  private async mutate<T>(
+    operationName: string,
+    hash: string,
+    variables: object,
+    referer: string,
+  ): Promise<T> {
+    const url = `${BASE_URL}/${operationName}/${hash}?operationName=${operationName}&locale=en&currency=USD`;
+
+    const res = await fetch(url, {
+      method: 'POST',
+      headers: this.headers({ Referer: referer, Origin: 'https://www.airbnb.com' }),
+      body: JSON.stringify({
+        operationName,
+        variables,
+        extensions: { persistedQuery: { version: 1, sha256Hash: hash } },
+      }),
+    });
+    if (res.status === 401) throw new AirbnbAuthError();
+    if (!res.ok) throw new Error(`${operationName} failed: ${res.status}\n${await res.text()}`);
+    const body = await res.json() as { errors?: Array<{ extensions?: { response?: { status?: number } } }> };
+    if (body.errors?.some(e => e.extensions?.response?.status === 401)) throw new AirbnbAuthError();
+    return body as T;
+  }
+
+  /**
+   * Fetch the host calendar for a date range.
+   * Use the first and last day of a month for a typical monthly view.
+   */
+  async getCalendar(startDate: string, endDate: string): Promise<CalendarResponse> {
+    return this.gql(
+      'getDLSHostCalendar',
+      HASHES.getDLSHostCalendar,
+      {
+        listingId: this.globalListingId,
+        startDate,
+        endDate,
+        hostCalendarViewType: 'MONTH_VIEW',
+        lensTypes: ['NOTE', 'PRICE', 'PROMOTION'],
+        timeZone: this.timeZone,
+        localizationContext: { timeZone: this.timeZone, firstDayOfWeek: 0 },
+        hostCalendarSectionTypes: ['MAIN_VIEW'],
+        readConsistency: 'EVENTUAL',
+      },
+      `https://www.airbnb.com/multicalendar/${this.listingId}`,
+    );
+  }
+
+  /**
+   * Fetch pricing and availability detail for a specific date or date range.
+   * Includes current price, suggested price, similar listings range, and guest total.
+   */
+  async getDateDetails(range: DateRange): Promise<DateDetailsResponse> {
+    return this.gql(
+      'EditPanelQuery',
+      HASHES.EditPanelQuery,
+      {
+        listingId: this.listingId,
+        globalListingId: this.globalListingId,
+        selectedDateRanges: [range],
+        selectedDateRangesPricingGuidance: [range],
+        isYearlyCalendar: false,
+        hostPricingCalculatorStartDate: range.startDate,
+        hostPricingCalculatorEndDate: addDays(range.startDate, 1),
+        hostPricingCalculatorMockIdentifier: null,
+        editPanelMockIdentifier: null,
+        isShowCalendarDayEventsEnabled: true,
+        calendarV2: true,
+        isNightlyPriceTipsModeOn: false,
+        blockNotesEnabled: true,
+      },
+      `https://www.airbnb.com/multicalendar/${this.listingId}/edit-selected-dates/${range.startDate}/${range.endDate}`,
+    );
+  }
+
+  /**
+   * Set the nightly price for a date range.
+   * Pass the same date for startDate and endDate to update a single day.
+   */
+  async setPrice(range: DateRange, nightlyPriceAmount: number): Promise<unknown> {
+    return this.mutate(
+      'EditPanelPricingSettingsMutation',
+      HASHES.EditPanelPricingSettingsMutation,
+      {
+        input: {
+          listingId: this.listingId,
+          selectedDateRanges: [range],
+          nightlyPriceAmount,
+          turnOnSmartPricing: false,
+          isCalendarV2: true,
+        },
+      },
+      `https://www.airbnb.com/multicalendar/${this.listingId}/edit-selected-dates/${range.startDate}/${range.endDate}/nightly-price`,
+    );
+  }
+
+  /**
+   * Block or unblock a date range.
+   * 'BLOCK' makes dates unavailable to guests; 'OPEN' makes them available.
+   */
+  async setAvailability(range: DateRange, actionType: AvailabilityAction): Promise<unknown> {
+    return this.mutate(
+      'EditPanelAvailabilityMutation',
+      HASHES.EditPanelAvailabilityMutation,
+      {
+        input: {
+          listingId: this.listingId,
+          selectedDateRanges: [range],
+          actionType,
+        },
+        blockNotesEnabled: true,
+      },
+      `https://www.airbnb.com/multicalendar/${this.listingId}/edit-selected-dates/${range.startDate}/${range.endDate}`,
+    );
+  }
+
+  /**
+   * Returns a Map of YYYY-MM-DD → { price, available } for every day in the given month.
+   * month is 1-based (1 = January, 12 = December).
+   */
+  async getPrices(month: number, year: number): Promise<Map<string, DayPriceInfo>> {
+    const { startDate, endDate } = monthBounds(month, year);
+    const response = await this.getCalendar(startDate, endDate);
+    const days = response.data.presentation.hostCalendar.sections.calendarGridViewSection?.days ?? [];
+
+    const map = new Map<string, DayPriceInfo>();
+    for (const day of days) {
+      map.set(day.day, {
+        price: day.priceData.price,
+        available: day.available,
+        booked: day.reservationData !== null,
+      });
+    }
+    return map;
+  }
+
+  /**
+   * Fetch guest name(s) and confirmation code for a reservation.
+   * Pass the confirmation code shown in the calendar (e.g. "HM4TAMTH3T").
+   */
+  async getReservationDetails(confirmationCode: string): Promise<ReservationDetails> {
+    const response = await this.gql<StayHostingDetailsResponse>(
+      'StayHostingDetailsQuery',
+      HASHES.StayHostingDetailsQuery,
+      {
+        viewerTimeZoneOffset: -new Date().getTimezoneOffset(),
+        requestSource: 'RESERVATION_TAB',
+        confirmationCode,
+      },
+      `https://www.airbnb.com/hosting/reservations/details/${confirmationCode}`,
+    );
+
+    const sections = response.data.presentation.hostingDetails.stayHostingDetails.rootPlacement;
+    const facepile = sections.find(s => s.sectionId === 'FACEPILE_SECTION');
+    const guests = (facepile?.sectionData.guests ?? []).map(g => ({
+      name: g.name,
+      userId: g.userId,
+    }));
+
+    return { confirmationCode, guests };
+  }
+}

+ 183 - 0
room_cal/service_api/typescript/airbnb/src/types.ts

@@ -0,0 +1,183 @@
+// ── Input types ──────────────────────────────────────────────────────────────
+
+export interface DateRange {
+  startDate: string; // YYYY-MM-DD
+  endDate: string;   // YYYY-MM-DD
+}
+
+export type AvailabilityAction = 'BLOCK' | 'OPEN';
+
+export interface AirbnbClientConfig {
+  /** Full cookie string copied from browser DevTools */
+  cookies: string;
+  /** Numeric listing ID, e.g. "1383515670686314979" */
+  listingId: string;
+  /** IANA timezone string, defaults to "America/Los_Angeles" */
+  timeZone?: string;
+}
+
+// ── getCalendar response ──────────────────────────────────────────────────────
+
+export interface CalendarDayPriceData {
+  currency: string;
+  price: number;
+  nativePrice: number;
+  promotionType: string | null;
+  anchorPrice: number | null;
+  suggestedPrice: number | null;
+  dayHasPriceTip: boolean;
+  smartPricingOverridden: boolean;
+  listingSmartPricingEnabled: boolean;
+}
+
+export interface CalendarReservationData {
+  __typename: 'HostCalendarReservationInfo';
+  confirmationCode: string;
+}
+
+export interface CalendarDay {
+  day: string;           // YYYY-MM-DD
+  available: boolean;
+  priceString: string | null;
+  strikethroughDate: boolean;
+  reservationData: CalendarReservationData | null;
+  priceData: CalendarDayPriceData;
+  isInteractive: boolean;
+  hasPrivateNotes: boolean;
+  hasBlockNotes: boolean;
+}
+
+export interface CalendarResponse {
+  data: {
+    presentation: {
+      hostCalendar: {
+        sections: {
+          calendarGridViewSection: {
+            days: CalendarDay[];
+            daysByMonthGridLayout: Array<{
+              rows: Array<Array<string | null>>;
+              title: string;
+            }>;
+            metadata: {
+              pricingAvailabilityModelType: string;
+            };
+          } | null;
+        };
+      };
+    };
+  };
+  extensions: { traceId: string };
+}
+
+// ── getDateDetails response ───────────────────────────────────────────────────
+
+export interface DisplayPrice {
+  currency: string;
+  micros: string;
+  formatted: string;
+  intValue: number;
+}
+
+export interface DateDetailsResponse {
+  data: {
+    presentation: {
+      hostCalendarEditPanelV2: {
+        pricingSettings: {
+          nightlyPrices: Array<{
+            date: string;
+            priceItem: { amount: number; currency: string; formattedAmount: string };
+            overrideSmartPricing: boolean;
+          }>;
+          nightlyPriceRange: string;
+          smartPricingEnabled: boolean;
+          nightlyPriceEditability: {
+            isEditable: boolean;
+            localizedUneditableReason: string | null;
+            editableScreen: string;
+          };
+        };
+        availabilitySettings: {
+          numberOfAvailableDays: number;
+          numberOfBlockedDays: number;
+          availabilities: Array<{ date: string; available: boolean }>;
+        };
+        availability: {
+          availability: 'AVAILABLE' | 'UNAVAILABLE';
+          explanation: string | null;
+          reason: string | null;
+        };
+        pricingGuidance: {
+          nightlyPriceTip: {
+            nightlyPriceTip: {
+              discretePriceTips: Array<{
+                shouldShowTip: boolean;
+                priceTip: { formatted: string; intValue: number };
+              }>;
+            };
+          } | null;
+          nightlyPriceSimilarListingsEntryPoint: {
+            range: {
+              type: string;
+              highPrice: DisplayPrice;
+              lowPrice: DisplayPrice;
+            } | null;
+          } | null;
+        } | null;
+        compset: {
+          formattedLowPriceBooked: string | null;
+          formattedHighPriceBooked: string | null;
+          formattedLowPriceAvailable: string | null;
+          formattedHighPriceAvailable: string | null;
+        } | null;
+      };
+      hostPricingCalculator: {
+        configuration: {
+          entryPointContent: {
+            titleText: string;  // "Guest total $112"
+          };
+        } | null;
+      } | null;
+    };
+  };
+  extensions: { traceId: string };
+}
+
+// ── getReservationDetails response ───────────────────────────────────────────
+
+export interface ReservationGuest {
+  name: string;
+  userId: string;
+}
+
+export interface ReservationDetails {
+  confirmationCode: string;
+  guests: ReservationGuest[];
+}
+
+export interface StayHostingDetailsResponse {
+  data: {
+    presentation: {
+      hostingDetails: {
+        stayHostingDetails: {
+          rootPlacement: Array<{
+            sectionId: string;
+            sectionData: {
+              guests?: Array<{
+                name: string;
+                userId: string;
+              }>;
+            };
+          }>;
+        };
+      };
+    };
+  };
+}
+
+// ── Convenience type returned by getPrices() ─────────────────────────────────
+
+export interface DayPriceInfo {
+  price: number;
+  available: boolean;
+  booked: boolean;
+}

+ 183 - 0
room_cal/service_api/typescript/booking/refresh-cookies.ts

@@ -0,0 +1,183 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import type { BrowserContext, Page } from 'playwright';
+import { filterCookies, parseOutputMode } from '../shared/cookie-utils';
+import { handleOutput, fetchSmsCode } from '../shared/api-client';
+import { openPersistentContext, profileDir } from '../shared/browser';
+import type { CredentialsPayload } from '../shared/types';
+
+async function handleAuthAssurance(page: Page, masked: string): Promise<void> {
+  console.error(`[booking] 2FA required (${masked || 'unknown number'}) — attempting SMS automation`);
+
+  // The challenge picker may show multiple options; try to click SMS.
+  try {
+    await page.getByRole('button', { name: /sms|text message/i }).first().click({ timeout: 5_000 });
+    console.error('[booking] Clicked SMS option');
+    await page.waitForTimeout(1_000);
+  } catch {
+    // Already on PIN entry, or SMS is the only option shown automatically — continue.
+  }
+
+  // Wait for the one-time-code input to appear.
+  const PIN_SEL = 'input[autocomplete="one-time-code"], input[inputmode="numeric"][maxlength], input[name*="pin"], input[name*="code"]';
+  let pinInput;
+  try {
+    pinInput = await page.waitForSelector(PIN_SEL, { timeout: 15_000 });
+  } catch {
+    console.error('[booking] No PIN input found — complete 2FA manually in the browser');
+    return;
+  }
+
+  console.error('[booking] Polling /sms/code for up to 2 minutes...');
+  const code = await fetchSmsCode(120);
+  if (!code) {
+    console.error('[booking] No code received — enter it manually in the browser');
+    return;
+  }
+
+  console.error(`[booking] Filling code ${code}`);
+  await pinInput.fill(code);
+  await page.keyboard.press('Enter');
+}
+
+const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+const ENV_PATH = path.join(DATA_HOME, 'booking.env');
+const ADMIN_BASE = 'admin.booking.com';
+const STRIP = new Set(['OptanonConsent', 'pcm_consent', 'uz']);
+const FORCE_HEADLESS = !!process.env.HEADLESS;
+
+export async function refresh(mode: string): Promise<CredentialsPayload> {
+  const profile = profileDir('booking', mode);
+  fs.mkdirSync(profile, { recursive: true });
+
+  const envText = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
+  const hotelId = envText.match(/^BOOKING_HOTEL_ID=(\S+)/m)?.[1] ?? '';
+  const ses = envText.match(/^BOOKING_SESSION=(\S+)/m)?.[1] ?? '';
+  const monthlyUrl = `https://${ADMIN_BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/monthly.html?hotel_id=${hotelId}&lang=en&ses=${ses}&view_mode=MONTHLY`;
+
+  let capturedCsrf: string | null = null;
+  let smsMasked = '';
+  const grabCsrf = (headers: Record<string, string>) => {
+    if (capturedCsrf) return;
+    const v = headers['x-booking-csrf'];
+    if (v && v !== 'undefined') capturedCsrf = v;
+  };
+
+  const openCtx = async (headless: boolean): Promise<BrowserContext> => {
+    const ctx = await openPersistentContext(profile, headless);
+    ctx.on('request', req => { if (req.url().includes(ADMIN_BASE)) grabCsrf(req.headers()); });
+    ctx.on('response', async res => {
+      if (res.url().includes(ADMIN_BASE)) grabCsrf(res.headers());
+      if (res.url().includes('challenge-methods')) {
+        try {
+          const body = await res.json() as { challengeMethods?: Array<{ type: string; phones?: Array<{ masked: string }> }> };
+          const sms = body.challengeMethods?.find(m => m.type === 'CHALLENGE_METHOD_SMS');
+          if (sms?.phones?.[0]?.masked) smsMasked = sms.phones[0].masked;
+        } catch {}
+      }
+    });
+    return ctx;
+  };
+
+  let headless = FORCE_HEADLESS || !!(hotelId && ses);
+  let context = await openCtx(headless);
+  let page = context.pages()[0] ?? await context.newPage();
+
+  if (headless) {
+    console.error('Trying silent refresh (headless)...');
+    await page.goto(monthlyUrl);
+    await page.waitForLoadState('domcontentloaded');
+
+    if (!page.url().includes('ses=')) {
+      if (FORCE_HEADLESS) {
+        await context.close();
+        throw new Error('Session expired — re-run without HEADLESS=1 to re-authenticate.');
+      }
+      console.error('Profile session expired — switching to headed browser for login...');
+      await context.close();
+      headless = false;
+      context = await openCtx(false);
+      page = context.pages()[0] ?? await context.newPage();
+    } else {
+      console.error('Session valid — continuing silently.');
+    }
+  }
+
+  if (!headless) {
+    console.error('Opening Booking.com extranet...');
+    await page.goto(hotelId && ses ? monthlyUrl : `https://${ADMIN_BASE}/`);
+    await page.waitForLoadState('domcontentloaded');
+
+    if (!page.url().includes('ses=')) {
+      console.error('Not logged in — log in and the script will continue automatically (5 min timeout)...');
+      await page.waitForURL(
+        url => url.toString().includes('ses=') || url.toString().includes('auth-assurance'),
+        { timeout: 300_000 },
+      );
+      if (!page.url().includes('ses=')) {
+        await handleAuthAssurance(page, smsMasked);
+        await page.waitForURL('**ses=**', { timeout: 120_000 });
+      }
+    }
+  }
+
+  const freshSes = new URL(page.url()).searchParams.get('ses') ?? ses;
+  if (!page.url().includes('monthly.html')) {
+    const targetUrl = `https://${ADMIN_BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/monthly.html?hotel_id=${hotelId}&lang=en&ses=${freshSes}&view_mode=MONTHLY`;
+    console.error('Navigating to monthly calendar...');
+    await page.goto(targetUrl);
+    await page.waitForLoadState('domcontentloaded');
+  }
+
+  console.error('Waiting for monthly calendar to render...');
+  const futureCell = page.locator('.av-monthly__cell:not(.av-monthly__cell-is-past)').first();
+  await futureCell.waitFor({ state: 'visible', timeout: 60_000 });
+  // CSRF is captured from API response headers during page load — no interaction needed.
+
+  const session = new URL(page.url()).searchParams.get('ses') ?? ses;
+
+  // Navigate to the messaging inbox to trigger and complete auth-assurance for chat access.
+  // This is a separate 2FA gate from the general session; without it, messaging GraphQL calls fail.
+  const inboxUrl = `https://${ADMIN_BASE}/hotel/hoteladmin/extranet_ng/manage/messaging/inbox.html?hotel_id=${hotelId}&lang=en&ses=${session}&source=nav&tlc=1`;
+  console.error('Navigating to messaging inbox to prime auth-assurance...');
+  await page.goto(inboxUrl);
+  await page.waitForLoadState('domcontentloaded');
+  if (page.url().includes('auth-assurance')) {
+    await handleAuthAssurance(page, smsMasked);
+    await page.waitForURL(`**${ADMIN_BASE}**`, { timeout: 120_000 });
+  }
+  console.error('Messaging inbox reached.');
+
+  console.error('Capturing cookies...');
+  const all = await context.cookies();
+  await context.close();
+
+  const cookieString = filterCookies(all, STRIP, 'booking.com');
+  if (!cookieString) throw new Error('No Booking.com cookies found.');
+  if (!session) throw new Error('No session token found.');
+
+  const credentials: CredentialsPayload = {
+    BOOKING_COOKIES: cookieString,
+    BOOKING_SESSION: session,
+  };
+  if (capturedCsrf) {
+    credentials.BOOKING_CSRF_TOKEN = capturedCsrf;
+    console.error('CSRF token captured.');
+  } else {
+    console.error('CSRF not captured — BOOKING_CSRF_TOKEN unchanged.');
+  }
+
+  console.error(`Done — ${all.filter(c => c.domain.includes('booking.com') && !STRIP.has(c.name)).length} cookies, session=${session.slice(0, 8)}…`);
+  return credentials;
+}
+
+async function main(): Promise<void> {
+  const mode = parseOutputMode(process.argv);
+  const credentials = await refresh(mode);
+  await handleOutput('booking', credentials, mode, ENV_PATH);
+}
+
+if (require.main === module) {
+  main().catch(err => { console.error(err.message); process.exit(1); });
+}

+ 396 - 0
room_cal/service_api/typescript/booking/src/client.ts

@@ -0,0 +1,396 @@
+import type {
+  BookingClientConfig,
+  Reservation,
+  RoomDateInfo,
+} from './types';
+
+const BASE = 'https://admin.booking.com';
+const REFERER_MONTHLY = (hotelId: number, lang: string, ses: string) =>
+  `${BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/monthly.html?hotel_id=${hotelId}&lang=${lang}&ses=${ses}&view_mode=MONTHLY`;
+const REFERER_MATRIX = (hotelId: number, lang: string, ses: string) =>
+  `${BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/index.html?view_mode=MATRIX&hotel_id=${hotelId}&lang=${lang}&ses=${ses}`;
+
+// Verbatim from network captures — this exact query text is what Booking.com's
+// partner-availability-extranet-mfe sends and validates server-side.
+const UPDATE_ROOM_DATE_MUTATION = `mutation updateRoomDateMutation($input: PartnerAvailabilityUpdateInput) {
+  partnerAvailabilityMutations {
+    partnerAvailabilityUpdate(input: $input) {
+      success
+      hotel {
+        rooms {
+          roomId
+          dates {
+            date
+            hidden
+            roomsToSell { changed value __typename }
+            status { changed value __typename }
+            statusReason { changed value __typename }
+            __typename
+          }
+          rates {
+            rateId
+            restrictionAdvice { date minStayThrough __typename }
+            dates {
+              date status statusReason
+              roomsToSell { changed value __typename }
+              priceSingleUse { changed value __typename }
+              price { occupancy value __typename }
+              minStayThrough { changed value __typename }
+              minStayArrival { changed value __typename }
+              minAdvanceRes { changed value __typename }
+              maxStayThrough { changed value __typename }
+              maxStayArrival { changed value __typename }
+              maxAdvanceRes { changed value __typename }
+              lengthOfStay { changed value __typename }
+              exactStayArrival { changed value __typename }
+              closedOnDeparture { changed value __typename }
+              closedOnArrival { changed value __typename }
+              closed
+              __typename
+            }
+            __typename
+          }
+          __typename
+        }
+        __typename
+      }
+      result {
+        warnings { message dates code __typename }
+        untilDate success fromDate
+        errors { dates message __typename }
+        __typename
+      }
+      __typename
+    }
+    __typename
+  }
+}`;
+
+function pageviewId(): string {
+  const hex = '0123456789abcdef';
+  return Array.from({ length: 16 }, () => hex[Math.floor(Math.random() * 16)]).join('');
+}
+
+export class BookingAuthError extends Error {
+  constructor() {
+    super('Booking.com session expired — update cookies and session token in config');
+    this.name = 'BookingAuthError';
+  }
+}
+
+export class BookingCsrfError extends Error {
+  constructor(method: string) {
+    super(`${method} requires csrfToken in BookingClientConfig — copy X-Booking-CSRF from browser DevTools`);
+    this.name = 'BookingCsrfError';
+  }
+}
+
+export class BookingClient {
+  private readonly cookies: string;
+  private readonly session: string;
+  private readonly hotelId: number;
+  private readonly hotelAccountId: number;
+  private readonly roomId: number;
+  private readonly rateId: string;
+  private readonly lang: string;
+  private readonly csrfToken: string | undefined;
+
+  constructor(config: BookingClientConfig) {
+    this.cookies = config.cookies;
+    this.session = config.session;
+    this.hotelId = config.hotelId;
+    this.hotelAccountId = config.hotelAccountId;
+    this.roomId = config.roomId;
+    this.rateId = config.rateId;
+    this.lang = config.lang ?? 'xu';
+    this.csrfToken = config.csrfToken;
+  }
+
+  private commonParams(): URLSearchParams {
+    const p = new URLSearchParams();
+    p.set('ses', this.session);
+    p.set('hotel_id', String(this.hotelId));
+    p.set('lang', this.lang);
+    p.set('hotel_account_id', String(this.hotelAccountId));
+    return p;
+  }
+
+  private restHeaders(referer: string, extra?: Record<string, string>): Record<string, string> {
+    return {
+      Accept: 'application/json, text/plain, */*',
+      'Accept-Language': 'en-US,en;q=0.9',
+      'Accept-Encoding': 'gzip, deflate, br',
+      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
+      Origin: BASE,
+      Referer: referer,
+      'Sec-Fetch-Mode': 'cors',
+      'Sec-Fetch-Dest': 'empty',
+      'Sec-Fetch-Site': 'same-origin',
+      'X-Booking-SiteType-Id': '31',
+      'X-Booking-Language-Code': 'en-us',
+      'X-Booking-Pageview-Id': pageviewId(),
+      Cookie: this.cookies,
+      ...extra,
+    };
+  }
+
+  private gqlHeaders(referer: string): Record<string, string> {
+    return {
+      Accept: '*/*',
+      'Accept-Language': 'en-US,en;q=0.9',
+      'Accept-Encoding': 'gzip, deflate, br',
+      'Content-Type': 'application/json',
+      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
+      Origin: BASE,
+      Referer: referer,
+      'Sec-Fetch-Mode': 'cors',
+      'Sec-Fetch-Dest': 'empty',
+      'Sec-Fetch-Site': 'same-origin',
+      'x-booking-site-type-id': '31',
+      'x-booking-context-action': 'hhemc_index',
+      'x-booking-context-action-name': 'hhemc_index',
+      'X-Booking-Pageview-ID': pageviewId(),
+      // Booking.com validates this header is present; the string "undefined" is
+      // what the browser MFE sends before it fetches the real token.
+      'x-booking-csrf-token': 'undefined',
+      'apollographql-client-name': 'b-partner-availability-extranet-mfe',
+      'apollographql-client-version': 'EQNeDROb',
+      'x-envoy-expected-rq-timeout-ms': '60000',
+      'x-booking-timeout-budget-ms': '60000',
+      'X-Booking-Topic': 'capla_browser_b-partner-availability-extranet-mfe',
+      Cookie: this.cookies,
+    };
+  }
+
+  private async checkAuth(res: Response): Promise<void> {
+    if (res.status === 401 || res.status === 403) throw new BookingAuthError();
+  }
+
+  private async post(url: string, headers: Record<string, string>, body?: BodyInit): Promise<unknown> {
+    const res = await fetch(url, { method: 'POST', headers, body });
+    await this.checkAuth(res);
+    if (!res.ok) throw new Error(`POST ${url} failed: ${res.status}\n${await res.text()}`);
+    return res.json();
+  }
+
+  private async multipartPost(url: string, referer: string, requestBody: object, extraHeaders?: Record<string, string>): Promise<unknown> {
+    const form = new FormData();
+    form.append('request', JSON.stringify(requestBody));
+    // Content-Type with boundary is set automatically when passing FormData
+    return this.post(url, this.restHeaders(referer, extraHeaders), form);
+  }
+
+  private async gqlMutate(variables: object): Promise<unknown> {
+    const referer = REFERER_MATRIX(this.hotelId, this.lang, this.session);
+    const url = `${BASE}/dml/graphql.json?view_mode=MATRIX&hotel_id=${this.hotelId}&lang=${this.lang}&ses=${this.session}`;
+    const body = JSON.stringify({
+      operationName: 'updateRoomDateMutation',
+      variables,
+      extensions: {},
+      query: UPDATE_ROOM_DATE_MUTATION,
+    });
+    const raw = await this.post(url, this.gqlHeaders(referer), body) as {
+      errors?: Array<{ message: string }>;
+      data?: {
+        partnerAvailabilityMutations?: {
+          partnerAvailabilityUpdate?: {
+            success: boolean;
+            result?: { errors?: Array<{ message: string }> };
+          };
+        };
+      };
+    };
+    if (raw.errors?.length) throw new Error(`GraphQL error: ${raw.errors.map(e => e.message).join(', ')}`);
+    const result = raw.data?.partnerAvailabilityMutations?.partnerAvailabilityUpdate;
+    if (!result?.success) {
+      const errs = result?.result?.errors?.map(e => e.message).join(', ');
+      throw new Error(`updateRoomDateMutation failed${errs ? `: ${errs}` : ''}`);
+    }
+    return raw;
+  }
+
+  /**
+   * Fetch inventory details (price, status, rooms, bookings) for a date range.
+   * Returns a Map of YYYY-MM-DD → RoomDateInfo for every date in the range.
+   */
+  async getDateInfo(dateFrom: string, dateTo: string): Promise<Map<string, RoomDateInfo>> {
+    const referer = REFERER_MONTHLY(this.hotelId, this.lang, this.session);
+    const url = `${BASE}/fresa/extranet/inventory/fetch?${this.commonParams()}`;
+    const requestBody = {
+      dates: { range: true, dates: [dateFrom, dateTo] },
+      hotel: {
+        fields: ['rooms', 'policygroups'],
+        rooms: {
+          id: [this.roomId],
+          fields: ['status', 'rooms_to_sell', 'rates', 'nr_rooms', 'net_booked', 'num_guests'],
+          rates: {
+            fields: ['price', 'status', 'rooms_to_sell', 'name'],
+          },
+        },
+        policy: { fields: ['info'] },
+      },
+      post_process_data_plugins: ['format_price', 'multi_units_check', 'last_update'],
+    };
+    // Response: data.hotel[hotelId].rooms[roomId].{dates, rates}
+    // Room dates have: status (string), hidden (0|1), rooms_to_sell, net_booked
+    // Rate dates have: price (Record<occupancy, string>), e.g. {"2": "91"}
+    const raw = await this.multipartPost(url, referer, requestBody) as {
+      data?: {
+        hotel?: Record<string, {
+          rooms?: Record<string, {
+            dates?: Record<string, {
+              status?: string;
+              rooms_to_sell?: number;
+              net_booked?: number;
+              hidden?: number;
+            }>;
+            rates?: Record<string, {
+              dates?: Record<string, {
+                price?: Record<string, string>;
+              }>;
+            }>;
+          }>;
+        }>;
+      };
+    };
+
+    const map = new Map<string, RoomDateInfo>();
+    const hotelData = raw.data?.hotel?.[String(this.hotelId)];
+    const roomData = hotelData?.rooms?.[String(this.roomId)];
+    if (!roomData?.dates) return map;
+
+    const rateDates = roomData.rates?.[String(this.rateId)]?.dates ?? {};
+
+    for (const [date, info] of Object.entries(roomData.dates)) {
+      const priceStr = rateDates[date]?.price?.['2'] ?? null;
+      const price = priceStr !== null ? Number(priceStr) : null;
+
+      map.set(date, {
+        price: Number.isFinite(price) ? price : null,
+        available: (info.hidden ?? 0) === 0,
+        roomsToSell: info.rooms_to_sell ?? 0,
+        netBooked: info.net_booked ?? 0,
+      });
+    }
+    return map;
+  }
+
+  /**
+   * Fetch reservations for a date range.
+   * Returns basic reservation info including IDs.
+   */
+  async getReservations(dateFrom: string, dateTo: string): Promise<Reservation[]> {
+    const params = this.commonParams();
+    params.set('from', dateFrom);
+    params.set('to', dateTo);
+    params.set('room_id', String(this.roomId));
+    params.set('only_basic_info', '1');
+    const url = `${BASE}/fresa/extranet/calendar/get_reservations?${params}`;
+    const referer = REFERER_MONTHLY(this.hotelId, this.lang, this.session);
+
+    // Response: data.reservations is an object keyed by reservation ID (string),
+    // not an array. Each entry has show_name, firstname, lastname, checkin, checkout.
+    const raw = await this.post(url, this.restHeaders(referer)) as {
+      data?: {
+        reservations?: Record<string, {
+          show_name?: string;
+          firstname?: string;
+          lastname?: string;
+          checkin?: string;
+          checkout?: string;
+          guests?: number;
+          completed_booking?: number;
+        }>;
+      };
+    };
+
+    return Object.entries(raw.data?.reservations ?? {}).map(([id, r]) => ({
+      id: Number(id),
+      checkIn: r.checkin ?? '',
+      checkOut: r.checkout ?? '',
+      status: r.completed_booking === 1 ? 'confirmed' : 'unknown',
+      guestName: r.show_name ?? (r.firstname && r.lastname ? `${r.firstname} ${r.lastname}` : null),
+    }));
+  }
+
+  /**
+   * Update the nightly price for a single date.
+   * Requires csrfToken in config.
+   * occupancy defaults to 2.
+   */
+  async setPrice(date: string, price: number, occupancy = 2): Promise<void> {
+    if (!this.csrfToken) throw new BookingCsrfError('setPrice');
+
+    const params = this.commonParams();
+    const url = `${BASE}/fresa/extranet/inventory/update?${params}`;
+    const referer = REFERER_MONTHLY(this.hotelId, this.lang, this.session);
+    const priceStr = String(price);
+    const requestBody = {
+      update: [{
+        room_id: this.roomId,
+        from_date: date,
+        until_date: date,
+        limit_to_weekdays: [0, 1, 2, 3, 4, 5, 6],
+        rate_id: this.rateId,
+        field_name: 'price',
+        field_value: priceStr,
+        occupancy,
+      }],
+      modified_fields: { price_changed: 1, price_changed_value: priceStr, date_selection_mode: 'CELLS' },
+      post_process_data_plugins: ['format_price', 'last_update'],
+      update_source: { product: 'calendar', extra_data: { new_calendar: 1, location: 'input' } },
+      status_reason: 1,
+      prevent_acav: '1',
+      check_adding_av_to_closed_property: '0',
+    };
+    await this.multipartPost(url, referer, requestBody, { 'X-Booking-CSRF': this.csrfToken });
+  }
+
+  /**
+   * Set the number of rooms to sell for a single date.
+   * Pass 0 to mark as sold out; 1 or more to restore availability.
+   */
+  async setRoomsToSell(date: string, count: number): Promise<void> {
+    const updateEntry: Record<string, unknown> = {
+      fromDate: date,
+      roomId: this.roomId,
+      untilDate: date,
+      roomsToSell: count,
+    };
+    if (count > 0) updateEntry['additionalChecks'] = { alertMode: 'WARN' };
+
+    await this.gqlMutate({
+      input: {
+        checkAddingAVToClosedProperty: false,
+        hotelId: this.hotelId,
+        postProcessDataPlugins: ['mlos_status_reason'],
+        preventAcav: true,
+        statusReason: true,
+        updateAvailability: [updateEntry],
+      },
+    });
+  }
+
+  /**
+   * Open or close a date for bookings.
+   * available=false hides the room from guests (equivalent to "closed").
+   */
+  async setAvailability(date: string, available: boolean): Promise<void> {
+    await this.gqlMutate({
+      input: {
+        checkAddingAVToClosedProperty: false,
+        hotelId: this.hotelId,
+        postProcessDataPlugins: ['mlos_status_reason'],
+        preventAcav: true,
+        statusReason: true,
+        updateAvailability: [{
+          fromDate: date,
+          roomId: this.roomId,
+          untilDate: date,
+          hidden: !available,
+          additionalChecks: { alertMode: 'WARN' },
+        }],
+      },
+    });
+  }
+}

+ 46 - 0
room_cal/service_api/typescript/booking/src/types.ts

@@ -0,0 +1,46 @@
+// ── Config ────────────────────────────────────────────────────────────────────
+
+export interface BookingClientConfig {
+  /** Full cookie string copied from browser DevTools */
+  cookies: string;
+  /** 'ses' query parameter from the admin URL */
+  session: string;
+  hotelId: number;
+  hotelAccountId: number;
+  roomId: number;
+  /** Rate ID for pricing updates (e.g. "57599423") */
+  rateId: string;
+  /** IANA timezone string, defaults to "America/Los_Angeles" */
+  timeZone?: string;
+  /** lang param, defaults to "xu" */
+  lang?: string;
+  /**
+   * X-Booking-CSRF token — required for setPrice() and getReservationDetails().
+   * Copy from browser DevTools on a page that makes those requests.
+   */
+  csrfToken?: string;
+}
+
+// ── Per-date summary ──────────────────────────────────────────────────────────
+
+export interface RoomDateInfo {
+  /** Nightly price in hotel currency, null if unavailable */
+  price: number | null;
+  /** False when the room is hidden/closed to guests */
+  available: boolean;
+  /** Number of rooms currently offered for sale */
+  roomsToSell: number;
+  /** Number of rooms with net bookings */
+  netBooked: number;
+}
+
+// ── Reservations ──────────────────────────────────────────────────────────────
+
+export interface Reservation {
+  id: number;
+  checkIn: string;   // YYYY-MM-DD
+  checkOut: string;  // YYYY-MM-DD
+  status: string;
+  guestName: string | null;
+}
+

+ 617 - 0
room_cal/service_api/typescript/package-lock.json

@@ -0,0 +1,617 @@
+{
+  "name": "room-cal-service-api",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "room-cal-service-api",
+      "version": "1.0.0",
+      "devDependencies": {
+        "@types/node": "^22.0.0",
+        "playwright": "^1.52.0",
+        "tsx": "^4.19.0",
+        "typescript": "^5.6.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+      "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+      "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+      "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+      "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+      "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+      "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+      "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+      "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+      "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+      "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+      "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+      "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+      "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+      "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+      "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+      "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+      "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+      "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+      "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+      "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+      "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+      "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+      "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+      "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+      "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+      "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "22.19.21",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
+      "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
+      "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.0",
+        "@esbuild/android-arm": "0.28.0",
+        "@esbuild/android-arm64": "0.28.0",
+        "@esbuild/android-x64": "0.28.0",
+        "@esbuild/darwin-arm64": "0.28.0",
+        "@esbuild/darwin-x64": "0.28.0",
+        "@esbuild/freebsd-arm64": "0.28.0",
+        "@esbuild/freebsd-x64": "0.28.0",
+        "@esbuild/linux-arm": "0.28.0",
+        "@esbuild/linux-arm64": "0.28.0",
+        "@esbuild/linux-ia32": "0.28.0",
+        "@esbuild/linux-loong64": "0.28.0",
+        "@esbuild/linux-mips64el": "0.28.0",
+        "@esbuild/linux-ppc64": "0.28.0",
+        "@esbuild/linux-riscv64": "0.28.0",
+        "@esbuild/linux-s390x": "0.28.0",
+        "@esbuild/linux-x64": "0.28.0",
+        "@esbuild/netbsd-arm64": "0.28.0",
+        "@esbuild/netbsd-x64": "0.28.0",
+        "@esbuild/openbsd-arm64": "0.28.0",
+        "@esbuild/openbsd-x64": "0.28.0",
+        "@esbuild/openharmony-arm64": "0.28.0",
+        "@esbuild/sunos-x64": "0.28.0",
+        "@esbuild/win32-arm64": "0.28.0",
+        "@esbuild/win32-ia32": "0.28.0",
+        "@esbuild/win32-x64": "0.28.0"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+      "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.60.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+      "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx": {
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+      "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "~0.28.0"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
+    "node_modules/tsx/node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    }
+  }
+}

+ 26 - 0
room_cal/service_api/typescript/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "room-cal-service-api",
+  "version": "1.0.0",
+  "description": "TypeScript clients and credential refresh tools for Airbnb and Booking.com",
+  "scripts": {
+    "refresh-airbnb": "tsx airbnb/refresh-cookies.ts",
+    "refresh-booking": "tsx booking/refresh-cookies.ts",
+    "refresh-all": "tsx scripts/refresh-all.ts",
+    "install-browsers": "tsx scripts/install-browsers.ts",
+    "check-credentials": "tsx scripts/check-credentials.ts",
+    "dump-session": "tsx scripts/dump-session.ts",
+    "intercept-session": "tsx scripts/intercept-session.ts",
+    "verify-api": "tsx scripts/verify-api.ts",
+    "record-fixtures": "tsx scripts/record-fixtures.ts",
+    "typecheck": "tsc --noEmit"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0",
+    "playwright": "^1.52.0",
+    "tsx": "^4.19.0",
+    "typescript": "^5.6.0"
+  },
+  "engines": {
+    "node": ">=18"
+  }
+}

+ 24 - 0
room_cal/service_api/typescript/scripts/check-credentials.ts

@@ -0,0 +1,24 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+
+const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+const SERVICES = ['airbnb', 'booking'];
+
+for (const service of SERVICES) {
+  const envPath = path.join(DATA_HOME, `${service}.env`);
+  console.log(`\n=== ${service} ===`);
+  if (!fs.existsSync(envPath)) {
+    console.log('  (no .env file)');
+    continue;
+  }
+  for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue;
+    const eqIdx = trimmed.indexOf('=');
+    const key = trimmed.slice(0, eqIdx);
+    const value = trimmed.slice(eqIdx + 1);
+    const display = key.endsWith('_UPDATED_AT') ? value : `[${value.length} chars]`;
+    console.log(`  ${key}=${display}`);
+  }
+}

+ 35 - 0
room_cal/service_api/typescript/scripts/dump-session.ts

@@ -0,0 +1,35 @@
+/**
+ * Print a redacted summary of current credential state.
+ * Shows which keys are set and truncated previews — safe to share in bug reports.
+ *
+ * Usage: tsx scripts/dump-session.ts
+ */
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+
+const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+const SERVICES = ['airbnb', 'booking'];
+
+function redact(key: string, value: string): string {
+  if (key.endsWith('_UPDATED_AT')) return value;
+  if (value.length <= 8) return '***';
+  return `${value.slice(0, 4)}…${value.slice(-4)} (${value.length} chars)`;
+}
+
+for (const service of SERVICES) {
+  const envPath = path.join(DATA_HOME, `${service}.env`);
+  console.log(`\n=== ${service} (${envPath}) ===`);
+  if (!fs.existsSync(envPath)) {
+    console.log('  NOT FOUND');
+    continue;
+  }
+  for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue;
+    const eqIdx = trimmed.indexOf('=');
+    const key = trimmed.slice(0, eqIdx);
+    const value = trimmed.slice(eqIdx + 1);
+    console.log(`  ${key}=${redact(key, value)}`);
+  }
+}

+ 9 - 0
room_cal/service_api/typescript/scripts/install-browsers.ts

@@ -0,0 +1,9 @@
+import { execFileSync } from 'child_process';
+import * as path from 'path';
+import * as os from 'os';
+
+const dataHome = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+process.env.PLAYWRIGHT_BROWSERS_PATH ??= path.join(dataHome, '.playwright-browsers');
+
+const cli = path.resolve(__dirname, '..', 'node_modules', '.bin', 'playwright');
+execFileSync(cli, ['install', 'chromium'], { stdio: 'inherit', env: process.env });

+ 51 - 0
room_cal/service_api/typescript/scripts/intercept-session.ts

@@ -0,0 +1,51 @@
+/**
+ * Intercept and log all API traffic from a service's admin site.
+ *
+ * Use when Airbnb or Booking.com changes their API — run this script,
+ * perform the action in the browser, and compare the captured traffic
+ * against what the Python client currently sends.
+ *
+ * Usage: SERVICE=airbnb tsx scripts/intercept-session.ts
+ */
+import * as path from 'path';
+import * as os from 'os';
+import { openPersistentContext, profileDir } from '../shared/browser';
+
+const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+const SERVICE = process.env.SERVICE ?? 'airbnb';
+
+const ENTRY_URLS: Record<string, string> = {
+  airbnb: 'https://www.airbnb.com/multicalendar',
+  booking: `https://admin.booking.com/`,
+};
+
+async function main(): Promise<void> {
+  const profile = profileDir(SERVICE);
+  const context = await openPersistentContext(profile, false);
+  const page = context.pages()[0] ?? await context.newPage();
+
+  const domain = SERVICE === 'airbnb' ? 'airbnb.com' : 'booking.com';
+
+  context.on('request', req => {
+    if (!req.url().includes(domain)) return;
+    console.log(`→ ${req.method()} ${req.url()}`);
+    const body = req.postData();
+    if (body) console.log(`  body: ${body.slice(0, 500)}`);
+  });
+
+  context.on('response', async res => {
+    if (!res.url().includes(domain)) return;
+    console.log(`← ${res.status()} ${res.url()}`);
+    try {
+      const body = await res.text();
+      if (body) console.log(`  response: ${body.slice(0, 500)}`);
+    } catch {}
+  });
+
+  await page.goto(ENTRY_URLS[SERVICE] ?? `https://${domain}/`);
+  console.log(`\nBrowser open — perform actions and watch traffic above. Close the browser to exit.\n`);
+
+  await context.waitForEvent('close');
+}
+
+main().catch(err => { console.error(err.message); process.exit(1); });

+ 92 - 0
room_cal/service_api/typescript/scripts/record-fixtures.ts

@@ -0,0 +1,92 @@
+/**
+ * Record raw API responses to JSON files for reference when updating the Python clients.
+ *
+ * Runs one API call per operation and saves the response under fixtures/<service>/<op>.json.
+ * Use when Airbnb or Booking.com changes a response shape and you need to update
+ * the Python parsing code.
+ *
+ * Usage: SERVICE=airbnb tsx scripts/record-fixtures.ts
+ *        SERVICE=booking tsx scripts/record-fixtures.ts
+ */
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { AirbnbClient } from '../airbnb/src/client';
+import { BookingClient } from '../booking/src/client';
+
+const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+const SERVICE = process.env.SERVICE ?? 'airbnb';
+const OUT_DIR = path.join(__dirname, '..', 'fixtures', SERVICE);
+
+function readEnv(service: string): Record<string, string> {
+  const envPath = path.join(DATA_HOME, `${service}.env`);
+  if (!fs.existsSync(envPath)) throw new Error(`${envPath} not found`);
+  const env: Record<string, string> = {};
+  for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue;
+    const eqIdx = trimmed.indexOf('=');
+    env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
+  }
+  return env;
+}
+
+function save(name: string, data: unknown): void {
+  fs.mkdirSync(OUT_DIR, { recursive: true });
+  const outPath = path.join(OUT_DIR, `${name}.json`);
+  fs.writeFileSync(outPath, JSON.stringify(data, null, 2));
+  console.log(`Saved ${outPath}`);
+}
+
+async function recordAirbnb(): Promise<void> {
+  const env = readEnv('airbnb');
+  const client = new AirbnbClient({ cookies: env.AIRBNB_COOKIES ?? '', listingId: env.AIRBNB_LISTING_ID ?? '' });
+  const today = new Date();
+  const month = today.getMonth() + 1;
+  const year = today.getFullYear();
+
+  const calResponse = await (client as any).getCalendar(
+    `${year}-${String(month).padStart(2, '0')}-01`,
+    `${year}-${String(month).padStart(2, '0')}-28`,
+  );
+  save('getCalendar', calResponse);
+}
+
+async function recordBooking(): Promise<void> {
+  const env = readEnv('booking');
+  const client = new BookingClient({
+    cookies: env.BOOKING_COOKIES ?? '',
+    session: env.BOOKING_SESSION ?? '',
+    hotelId: Number(env.BOOKING_HOTEL_ID),
+    hotelAccountId: Number(env.BOOKING_ACCOUNT_ID),
+    roomId: Number(env.BOOKING_ROOM_ID),
+    rateId: env.BOOKING_RATE_ID ?? '',
+  });
+  const today = new Date().toISOString().slice(0, 10);
+  const raw = await (client as any).multipartPost(
+    'https://admin.booking.com/fresa/extranet/inventory/fetch',
+    `https://admin.booking.com/hotel/hoteladmin/extranet_ng/manage/calendar/monthly.html`,
+    {
+      dates: { range: true, dates: [today, today] },
+      hotel: {
+        fields: ['rooms', 'policygroups'],
+        rooms: {
+          id: [Number(env.BOOKING_ROOM_ID)],
+          fields: ['status', 'rooms_to_sell', 'rates', 'nr_rooms', 'net_booked'],
+          rates: { fields: ['price', 'status', 'rooms_to_sell', 'name'] },
+        },
+        policy: { fields: ['info'] },
+      },
+      post_process_data_plugins: ['format_price', 'multi_units_check', 'last_update'],
+    },
+  );
+  save('fetchInventory', raw);
+}
+
+async function main(): Promise<void> {
+  if (SERVICE === 'airbnb') await recordAirbnb();
+  else if (SERVICE === 'booking') await recordBooking();
+  else throw new Error(`Unknown SERVICE=${SERVICE}. Use 'airbnb' or 'booking'.`);
+}
+
+main().catch(err => { console.error(err.message); process.exit(1); });

+ 23 - 0
room_cal/service_api/typescript/scripts/refresh-all.ts

@@ -0,0 +1,23 @@
+import { refresh as refreshAirbnb } from '../airbnb/refresh-cookies';
+import { refresh as refreshBooking } from '../booking/refresh-cookies';
+import { handleOutput } from '../shared/api-client';
+import { parseOutputMode } from '../shared/cookie-utils';
+import * as path from 'path';
+import * as os from 'os';
+
+const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+const mode = parseOutputMode(process.argv);
+
+async function main(): Promise<void> {
+  const [airbnbCreds, bookingCreds] = await Promise.all([
+    refreshAirbnb(mode),
+    refreshBooking(mode),
+  ]);
+  await Promise.all([
+    handleOutput('airbnb', airbnbCreds, mode, path.join(DATA_HOME, 'airbnb.env')),
+    handleOutput('booking', bookingCreds, mode, path.join(DATA_HOME, 'booking.env')),
+  ]);
+  if (mode !== 'stdout') console.error('\nAll credentials refreshed.');
+}
+
+main().catch(err => { console.error(err.message); process.exit(1); });

+ 67 - 0
room_cal/service_api/typescript/scripts/verify-api.ts

@@ -0,0 +1,67 @@
+/**
+ * Smoke-test live credentials by making a real API call and reporting success/failure.
+ *
+ * Use after refreshing credentials or after updating the client code to confirm
+ * the Python API client is still working.
+ *
+ * Usage: tsx scripts/verify-api.ts [airbnb|booking]
+ */
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { AirbnbClient } from '../airbnb/src/client';
+import { BookingClient } from '../booking/src/client';
+
+const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+const SERVICE = process.argv[2] ?? 'all';
+
+function readEnv(service: string): Record<string, string> {
+  const envPath = path.join(DATA_HOME, `${service}.env`);
+  if (!fs.existsSync(envPath)) throw new Error(`${envPath} not found`);
+  const env: Record<string, string> = {};
+  for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue;
+    const eqIdx = trimmed.indexOf('=');
+    env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
+  }
+  return env;
+}
+
+async function verifyAirbnb(): Promise<void> {
+  const env = readEnv('airbnb');
+  const client = new AirbnbClient({
+    cookies: env.AIRBNB_COOKIES ?? '',
+    listingId: env.AIRBNB_LISTING_ID ?? '',
+  });
+  const today = new Date();
+  const month = today.getMonth() + 1;
+  const year = today.getFullYear();
+  const prices = await client.getPrices(month, year);
+  console.log(`[airbnb] OK — fetched ${prices.size} days for ${year}-${String(month).padStart(2, '0')}`);
+}
+
+async function verifyBooking(): Promise<void> {
+  const env = readEnv('booking');
+  const client = new BookingClient({
+    cookies: env.BOOKING_COOKIES ?? '',
+    session: env.BOOKING_SESSION ?? '',
+    hotelId: Number(env.BOOKING_HOTEL_ID),
+    hotelAccountId: Number(env.BOOKING_ACCOUNT_ID),
+    roomId: Number(env.BOOKING_ROOM_ID),
+    rateId: env.BOOKING_RATE_ID ?? '',
+    csrfToken: env.BOOKING_CSRF_TOKEN,
+  });
+  const today = new Date().toISOString().slice(0, 10);
+  const info = await client.getDateInfo(today, today);
+  console.log(`[booking] OK — fetched ${info.size} day(s) for ${today}`);
+}
+
+async function main(): Promise<void> {
+  const tasks: Promise<void>[] = [];
+  if (SERVICE === 'all' || SERVICE === 'airbnb') tasks.push(verifyAirbnb());
+  if (SERVICE === 'all' || SERVICE === 'booking') tasks.push(verifyBooking());
+  await Promise.all(tasks);
+}
+
+main().catch(err => { console.error(`FAIL: ${err.message}`); process.exit(1); });

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików