"""Bounded read-only reservation/day rate lookup from the published Edge0.7 contract. Standalone integration primitive. It does not change v1 captures, build XML or submit Finance. GET is kept for diagnostic compatibility; new lookups use POST. """ from __future__ import annotations from dataclasses import dataclass from decimal import Decimal import json import re import time import urllib.error import urllib.parse from . import collect_arr_source as source POST = "searchRateInfo" GET = "getRateInfo" PATH = "/api/v1/reservations/rate-info" require = source.require def request(reservation_id: str, day: str, operation: str = POST): source.Options(day, "request-validation").validate() require(isinstance(reservation_id, str) and bool(re.fullmatch(r"[A-Za-z0-9_-]{1,128}", reservation_id)), "invalid_reservation_id") body = {"id": reservation_id, "type": "Reservation", "summaryInfo": False, "detailDate": day} if operation == POST: return "POST", PATH + "/searches", source.json_bytes(body), body require(operation == GET, "operation_not_allowed") query = {**body, "summaryInfo": "false"} return "GET", PATH + "?" + urllib.parse.urlencode(query), None, None @dataclass(frozen=True) class DayRateCandidate: effective_rate: Decimal currency: str # Neither reservation/date echo nor same-source report equivalence is implied. report_equivalence_verified: bool = False def day_rate_candidate(data: dict, *, expected_currency: str | None = None) -> DayRateCandidate: require(isinstance(data, dict) and bool(data), "empty_rate_info") source.check_warnings(data) require("summary" not in data, "unexpected_rate_summary") detail = data.get("detail") require(isinstance(detail, dict) and bool(detail), "missing_rate_detail") if "rateSuppressed" in detail: require(type(detail["rateSuppressed"]) is bool, "invalid_rate_suppression_flag") require(not detail["rateSuppressed"], "rate_suppressed") amount = detail.get("totalRateAmount") require(type(amount) in {int, Decimal}, "missing_or_invalid_effective_rate") value = Decimal(str(amount)) require(value.is_finite() and value >= 0, "missing_or_invalid_effective_rate") revenue = detail.get("revenue") currency = revenue.get("currencyCode") if isinstance(revenue, dict) else None require(isinstance(currency, str) and bool(re.fullmatch(r"[A-Z]{3}", currency)), "missing_or_invalid_rate_currency") require(expected_currency is None or currency == expected_currency, "rate_currency_mismatch") return DayRateCandidate(value, currency) class RateInfoReader: def __init__(self, archive: source.Archive, hotel_id: str, transport, *, key: str = "", sleep=time.sleep): source.Options("2000-01-01", hotel_id).validate() self.archive, self.hotel_id, self.transport = archive, hotel_id, transport self._key, self.sleep = key, sleep self.request_count = 0 def read(self, reservation_id: str, day: str, *, operation: str = POST) -> dict: method, path, payload, body = request(reservation_id, day, operation) for attempt in range(1, 4): self.request_count += 1 label = f"rate-{self.request_count:06d}" self.archive.write(label + ".json", {"operation_id": operation, "method": method, "path": path, "body": body, "attempt": attempt, "started_at": source.utc_now()}) try: status, headers, raw = self.transport(method, path, payload) except (urllib.error.URLError, TimeoutError, ConnectionError): self.archive.write(label + ".meta.json", {"error": "transport_failure", "ended_at": source.utc_now()}) if attempt == 3: raise source.CollectionError("transport_retry_exhausted") from None self.sleep(float(2 ** (attempt - 1))) continue require(not self._key or self._key.encode() not in raw, "secret_in_response") headers = {k.lower(): v for k, v in headers.items()} self.archive.write(label + ".response.bin", raw) self.archive.write(label + ".meta.json", {"http_status": status, "ended_at": source.utc_now(), "oversized": len(raw) > source.MAX_RESPONSE_BYTES, "retry_after": headers.get("retry-after"), "edge_request_id": headers.get("x-request-id")}) require(len(raw) <= source.MAX_RESPONSE_BYTES, "response_too_large") if status in source.RETRY_STATUSES and attempt < 3: self.sleep(source.retry_delay(headers, attempt)) continue require(status == 200, "http_failure") source.strict_json(raw) # Enforce duplicate-key/constant/shape guards. document = json.loads(raw, parse_float=Decimal) # Preserve decimal monetary bytes. require(document.get("operation_id") == operation, "operation_mismatch") require(document.get("hotel_id") == self.hotel_id, "hotel_mismatch") require(isinstance(document.get("oracle_request_id"), str) and bool(document["oracle_request_id"]), "missing_oracle_request_id") require(isinstance(document.get("data"), dict), "invalid_data_envelope") source.check_warnings(document) return document["data"] raise source.CollectionError("retry_exhausted")