Files
ARR-2.0-0918/integrations/ohip/rate_info.py

108 lines
5.3 KiB
Python

"""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")