""" Newbook API Client Service Handles authentication and API calls to Newbook PMS. Base URL: https://api.newbook.cloud/rest/ Auth: HTTP Basic Auth (username:password) + API Key + Region in request body """ import logging import httpx from datetime import date from typing import Optional, Any from decimal import Decimal logger = logging.getLogger(__name__) # Single base URL for all regions - region is passed in request body NEWBOOK_BASE_URL = "https://api.newbook.cloud/rest/" # Valid regions (passed in request body, not URL) VALID_REGIONS = ["au", "ap", "eu", "us", "uk"] class NewbookAPIError(Exception): """Custom exception for Newbook API errors""" def __init__(self, message: str, status_code: int = None, response_data: dict = None): self.message = message self.status_code = status_code self.response_data = response_data super().__init__(self.message) class NewbookAPIClient: """ Async client for Newbook REST API. Usage: async with NewbookAPIClient(username, password, api_key, region, instance_id) as client: accounts = await client.get_gl_accounts() revenue = await client.get_earned_revenue(date_from, date_to) """ def __init__( self, username: str, password: str, api_key: str, region: str = "au", instance_id: str = None ): self.username = username self.password = password self.api_key = api_key self.region = region # Passed in request body self.instance_id = instance_id self.base_url = NEWBOOK_BASE_URL self._client: httpx.AsyncClient = None async def __aenter__(self): self._client = httpx.AsyncClient( auth=(self.username, self.password), timeout=httpx.Timeout(30.0, connect=15.0), follow_redirects=True, ) return self async def __aexit__(self, exc_type, exc_val, exc_tb): if self._client: await self._client.aclose() async def _request(self, endpoint: str, payload: dict = None) -> dict: """ Make an authenticated request to Newbook API. All requests include api_key in the body. """ if payload is None: payload = {} # Always include region and api_key in request body payload["region"] = self.region payload["api_key"] = self.api_key # Include instance_id if configured if self.instance_id: payload["instance_id"] = self.instance_id url = f"{self.base_url}{endpoint}" try: logger.info(f"Newbook API request: POST {endpoint}") response = await self._client.post(url, json=payload) if response.status_code == 401: raise NewbookAPIError("Authentication failed. Check username/password.", 401) if response.status_code == 403: raise NewbookAPIError("Access denied. Check API key and permissions.", 403) response.raise_for_status() data = response.json() # Check for Newbook-specific error responses if isinstance(data, dict) and data.get("success") is False: error_msg = data.get("message", "Unknown Newbook API error") raise NewbookAPIError(error_msg, response.status_code, data) return data except httpx.HTTPStatusError as e: logger.error(f"Newbook API HTTP error: {e.response.status_code}") raise NewbookAPIError(f"HTTP {e.response.status_code}: {str(e)}", e.response.status_code) except httpx.RequestError as e: logger.error(f"Newbook API request error: {e}") raise NewbookAPIError(f"Request failed: {str(e)}") async def test_connection(self) -> bool: """Test API connection by fetching GL accounts (lightweight call)""" try: await self.get_gl_accounts() return True except NewbookAPIError: return False async def get_gl_accounts(self) -> list[dict]: """ Fetch list of GL accounts from Newbook. Endpoint: gl_account_list Note: Newbook returns individual GL accounts with group info. Each item has both gl_account_id/gl_account_name (individual) and gl_group_id/gl_group_name (category). Returns list of individual GL accounts with: - id: GL account ID (gl_account_id) - code: Account code - name: Account name (gl_account_name) - group_id: Parent group ID (gl_group_id) - group_name: Parent group name (gl_group_name) - type: Account type """ response = await self._request("gl_account_list") # Normalize response format accounts = {} # Use dict to dedupe by gl_account_id items = response.get("data", response) if isinstance(response, dict) else response if isinstance(items, list): for item in items: # Get individual account ID (prefer gl_account_id, fall back to id) gl_account_id = str(item.get("gl_account_id", item.get("id", ""))) gl_account_name = item.get("gl_account_name", item.get("name", "")) # Get group info for categorization gl_group_id = str(item.get("gl_group_id", "")) gl_group_name = item.get("gl_group_name", "") if not gl_account_id or gl_account_id in accounts: continue # Extract code from account name or use account ID code = item.get("gl_account_code", item.get("code", "")) if not code and " - " in gl_account_name: code = gl_account_name.split(" - ")[0].strip() accounts[gl_account_id] = { "id": gl_account_id, "code": code, "name": gl_account_name, "group_id": gl_group_id, "group_name": gl_group_name, "type": item.get("gl_type", item.get("type", "")) } result = list(accounts.values()) logger.info(f"Fetched {len(result)} GL accounts from Newbook") return result async def get_earned_revenue( self, date_from: date, date_to: date, gl_account_ids: list[str] = None ) -> list[dict]: """ Fetch earned revenue report - requests one day at a time to get daily breakdown. Endpoint: reports_earned_revenue Note: Newbook returns period totals per GL account, not daily breakdown. We request each day individually to get daily revenue per GL account. Rate limited to ~80 requests/min to stay under Newbook's 100/min limit. Args: date_from: Start date date_to: End date gl_account_ids: Optional list of GL account IDs to filter Returns list of daily revenue entries: - date: Date (ISO string) - gl_account_id: GL Account ID - gl_account_name: GL Account name - amount_net: Net amount (exc tax) - amount_gross: Gross amount (inc tax) if available """ import asyncio from datetime import timedelta revenue_entries = [] current_date = date_from request_count = 0 # Rate limit: ~80 requests/min to stay under 100/min limit # 0.75 seconds between requests = 80 requests/min RATE_LIMIT_DELAY = 0.75 # Request each day individually to get daily breakdown while current_date <= date_to: payload = { "period_from": current_date.isoformat(), "period_to": current_date.isoformat(), } if gl_account_ids: payload["gl_account_ids"] = gl_account_ids try: response = await self._request("reports_earned_revenue", payload) request_count += 1 items = response.get("data", response) if isinstance(response, dict) else response # Log first response to debug field names if request_count == 1: logger.info(f"Earned revenue first response sample: {items[:2] if isinstance(items, list) else items}") if isinstance(items, list): for item in items: # Newbook returns: earned_revenue_ex (net), earned_revenue (gross) # Also check legacy field names as fallback amount_net = Decimal(str( item.get("earned_revenue_ex") or item.get("amount_net") or item.get("amount", 0) or 0 )) # Skip zero amounts if amount_net == 0: continue # GL account code is in gl_account_code field gl_code = str( item.get("gl_account_code") or item.get("gl_account_id", "") ) gl_name = ( item.get("gl_account_description") or item.get("gl_account_name", "") ) entry = { "date": current_date.isoformat(), "gl_account_id": gl_code, # Note: this is actually the code, not internal ID "gl_account_name": gl_name, "amount_net": amount_net, "amount_gross": None } # Gross amount is earned_revenue (inc tax) gross = item.get("earned_revenue") or item.get("amount_gross") if gross: entry["amount_gross"] = Decimal(str(gross)) revenue_entries.append(entry) except NewbookAPIError as e: logger.warning(f"Failed to fetch revenue for {current_date}: {e}") current_date += timedelta(days=1) # Rate limiting delay between requests if current_date <= date_to: await asyncio.sleep(RATE_LIMIT_DELAY) logger.info(f"Fetched {len(revenue_entries)} revenue entries from Newbook ({date_from} to {date_to}, {request_count} requests)") return revenue_entries async def get_occupancy_report( self, date_from: date, date_to: date ) -> list[dict]: """ Fetch occupancy report. Endpoint: reports_occupancy Note: Newbook returns data grouped by room category. Each category has an 'occupancy' dict with dates as keys. We aggregate across all categories to get daily totals. Returns list of daily occupancy data: - date - total_rooms - occupied_rooms - occupancy_percentage - total_guests (estimated from occupied rooms) """ payload = { "period_from": date_from.isoformat(), "period_to": date_to.isoformat(), } response = await self._request("reports_occupancy", payload) # Aggregate occupancy across all room categories by date daily_totals = {} # date -> {available, occupied, maintenance, adults, children} items = response.get("data", response) if isinstance(response, dict) else response if isinstance(items, list): for category in items: # Each category has an 'occupancy' dict with dates as keys category_occupancy = category.get("occupancy", {}) if isinstance(category_occupancy, dict): for date_str, day_data in category_occupancy.items(): # Debug: log first day's data structure to see guest field format if not daily_totals: logger.info(f"Occupancy day_data sample keys: {list(day_data.keys()) if isinstance(day_data, dict) else 'not dict'}") logger.info(f"Occupancy day_data sample: {day_data}") if date_str not in daily_totals: daily_totals[date_str] = { "available": 0, "occupied": 0, "maintenance": 0, "adults": 0, "children": 0 } daily_totals[date_str]["available"] += day_data.get("available", 0) or 0 daily_totals[date_str]["occupied"] += day_data.get("occupied", 0) or 0 daily_totals[date_str]["maintenance"] += day_data.get("maintenance", 0) or 0 # Parse guest counts - can be direct fields or in arrays # Handle array format: [adults, children, infants] or [{type, count}, ...] guests_data = day_data.get("guests", day_data.get("people", None)) if isinstance(guests_data, list): if len(guests_data) >= 2: # Check if it's [adults, children, infants] format (numbers) if isinstance(guests_data[0], (int, float)): daily_totals[date_str]["adults"] += int(guests_data[0] or 0) daily_totals[date_str]["children"] += int(guests_data[1] or 0) # Ignore infants at index 2 # Check if it's [{type, count}, ...] format elif isinstance(guests_data[0], dict): for guest_item in guests_data: guest_type = str(guest_item.get("type", guest_item.get("name", ""))).lower() count = int(guest_item.get("count", guest_item.get("quantity", 0)) or 0) if "adult" in guest_type: daily_totals[date_str]["adults"] += count elif "child" in guest_type: daily_totals[date_str]["children"] += count # Ignore infants else: # Try direct fields daily_totals[date_str]["adults"] += int(day_data.get("adults", 0) or 0) daily_totals[date_str]["children"] += int(day_data.get("children", 0) or 0) # Convert to list format occupancy_data = [] for date_str, totals in sorted(daily_totals.items()): total_rooms = totals["available"] occupied_rooms = totals["occupied"] total_guests = totals["adults"] + totals["children"] # Calculate occupancy percentage occupancy_pct = None if total_rooms > 0: occupancy_pct = Decimal(str(round(occupied_rooms / total_rooms * 100, 2))) # Fall back to occupied_rooms if no guest data if total_guests == 0: total_guests = occupied_rooms occupancy_data.append({ "date": date_str, "total_rooms": total_rooms, "occupied_rooms": occupied_rooms, "occupancy_percentage": occupancy_pct, "total_guests": total_guests, }) logger.info(f"Fetched {len(occupancy_data)} daily occupancy records from Newbook (aggregated from {len(items) if items else 0} categories)") return occupancy_data async def get_bookings( self, date_from: date, date_to: date, include_cancelled: bool = False ) -> list[dict]: """ Fetch bookings list with inventory items. Endpoint: bookings_list Note: Newbook paginates results (default 100, max 1000 per request). This method automatically fetches all pages. Args: date_from: Check-in date from date_to: Check-in date to include_cancelled: Include cancelled bookings Returns list of bookings with inventory items """ bookings = [] data_offset = 0 data_limit = 1000 # Max allowed by Newbook total_fetched = 0 while True: payload = { "period_from": date_from.isoformat(), "period_to": date_to.isoformat(), "list_type": "staying", # Get bookings staying on these dates "data_offset": data_offset, "data_limit": data_limit, } response = await self._request("bookings_list", payload) # Get pagination info from response data_total = response.get("data_total", 0) data_count = response.get("data_count", 0) items = response.get("data", response) if isinstance(response, dict) else response if isinstance(items, list): for item in items: status = item.get("status", "").lower() if not include_cancelled and status == "cancelled": continue # Parse guest count - use booking_adults + booking_children directly # Newbook provides these as separate fields, excluding infants (who don't eat full meals) adults = int(item.get("booking_adults", 0) or 0) children = int(item.get("booking_children", 0) or 0) infants = int(item.get("booking_infants", 0) or 0) num_guests = adults + children # Fall back to 1 only if no guest data at all (single occupancy assumed) # Don't fall back if there are only infants - they don't count for meals if num_guests == 0 and infants == 0: num_guests = 1 # Log first booking's structure to debug if len(bookings) == 0: logger.info(f"First booking guest data - adults: {item.get('booking_adults')}, children: {item.get('booking_children')}, infants: {item.get('booking_infants')} => counted: {num_guests}") logger.info(f"First booking dates - arrival: {item.get('booking_arrival')}, departure: {item.get('booking_departure')}") logger.info(f"First booking category_id: {item.get('category_id')}") # Get room category/type - use category_id which maps to room types # category_id is the numeric ID that corresponds to room types like "Standard", "Suite" category_id = item.get("category_id") or item.get("site_category_id") or "" room_type_name = ( item.get("site_type") or item.get("category_name") or item.get("room_type_name") or "" ) # Log first booking's room fields to debug matching if len(bookings) == 0: logger.info(f"First booking room fields - category_id: {category_id}, site_type: {item.get('site_type')}, site_name: {item.get('site_name')}") # Get booking group ID for related bookings (e.g., family/party traveling together) bookings_group_id = item.get("bookings_group_id") if bookings_group_id: bookings_group_id = str(bookings_group_id) bookings.append({ "booking_id": str(item.get("id", item.get("booking_id", ""))), "booking_reference": item.get("reference", item.get("booking_reference", item.get("booking_reference_id"))), "bookings_group_id": bookings_group_id, # Group ID for related bookings "check_in_date": item.get("booking_arrival", item.get("check_in", item.get("check_in_date"))), "check_out_date": item.get("booking_departure", item.get("check_out", item.get("check_out_date"))), "nights": item.get("booking_length", item.get("nights")), "room_type": room_type_name, # Room type name if available "category_id": str(category_id) if category_id else "", # Category ID for filtering "site_id": item.get("site_id"), # Individual room ID "site_name": item.get("site_name"), # Room number (e.g. "108") "num_guests": num_guests, "booking_adults": int(item.get("booking_adults", 0) or 0), # Debug "booking_children": int(item.get("booking_children", 0) or 0), # Debug "booking_infants": int(item.get("booking_infants", 0) or 0), # Debug "num_rooms": item.get("rooms", item.get("num_rooms", 1)), "total_amount": Decimal(str(item.get("total", 0))) if item.get("total") else None, "status": status, "inventory_items": item.get("inventory_items", item.get("items", [])) }) total_fetched += len(items) # Check if we've fetched all records if data_count == 0 or total_fetched >= data_total: break # Move to next page data_offset += data_limit logger.info(f"Fetching next page of bookings (offset: {data_offset}, total: {data_total})") logger.info(f"Fetched {len(bookings)} bookings from Newbook (total available: {data_total})") return bookings def process_bookings_for_allocations( self, bookings: list[dict], breakfast_gl_codes: list[str], dinner_gl_codes: list[str], gl_account_id_to_code: dict[str, str] = None, breakfast_vat_rate: Decimal = None, dinner_vat_rate: Decimal = None ) -> dict[str, dict]: """ Process bookings inventory items to calculate meal allocations per date. Args: bookings: List of bookings with inventory_items breakfast_gl_codes: GL codes that indicate breakfast allocation dinner_gl_codes: GL codes that indicate dinner allocation gl_account_id_to_code: Mapping from Newbook gl_account_id to gl_code breakfast_vat_rate: VAT rate for breakfast (e.g., 0.10 for 10%) dinner_vat_rate: VAT rate for dinner (e.g., 0.10 for 10%) Returns dict keyed by date with: - breakfast_qty: Total breakfast allocations - breakfast_netvalue: Total breakfast net value (exc VAT) - dinner_qty: Total dinner allocations - dinner_netvalue: Total dinner net value (exc VAT) """ allocations_by_date = {} gl_account_id_to_code = gl_account_id_to_code or {} # Default VAT rates if not provided breakfast_vat_rate = breakfast_vat_rate or Decimal("0.10") dinner_vat_rate = dinner_vat_rate or Decimal("0.10") logger.info(f"Processing {len(bookings)} bookings for allocations") logger.info(f"Breakfast GL codes: {breakfast_gl_codes}, Dinner GL codes: {dinner_gl_codes}") logger.info(f"VAT rates - Breakfast: {breakfast_vat_rate}, Dinner: {dinner_vat_rate}") logger.info(f"GL account ID to code mapping has {len(gl_account_id_to_code)} entries") for booking in bookings: inventory_items = booking.get("inventory_items", []) if not inventory_items: continue # Get guest count for this booking - PAX should be based on guests, not item qty booking_guests = booking.get("num_guests", 1) or 1 for item in inventory_items: # Newbook inventory items use gl_account_id (internal ID), not gl_code gl_account_id = str(item.get("gl_account_id", "")) # Translate to gl_code using our mapping gl_code = gl_account_id_to_code.get(gl_account_id, "") # Date is in stay_date field item_date = item.get("stay_date", item.get("date", item.get("item_date"))) # PAX is the number of guests in the booking, not the inventory item qty # (inventory items are typically 1 per booking per day, but represent all guests) pax = booking_guests # Amount field - Newbook returns gross (inc VAT) gross_amount = Decimal(str(item.get("amount", item.get("net_amount", 0)) or 0)) if not item_date or not gl_code: continue if item_date not in allocations_by_date: allocations_by_date[item_date] = { "breakfast_qty": 0, "breakfast_netvalue": Decimal("0"), "dinner_qty": 0, "dinner_netvalue": Decimal("0"), } # Check if this item matches breakfast or dinner GL codes # Calculate net from gross: net = gross / (1 + vat_rate) if gl_code in breakfast_gl_codes: net_amount = gross_amount / (1 + breakfast_vat_rate) allocations_by_date[item_date]["breakfast_qty"] += pax allocations_by_date[item_date]["breakfast_netvalue"] += net_amount.quantize(Decimal("0.01")) elif gl_code in dinner_gl_codes: net_amount = gross_amount / (1 + dinner_vat_rate) allocations_by_date[item_date]["dinner_qty"] += pax allocations_by_date[item_date]["dinner_netvalue"] += net_amount.quantize(Decimal("0.01")) logger.info(f"Found allocations for {len(allocations_by_date)} dates") return allocations_by_date async def get_site_list(self) -> list[dict]: """ Fetch site/room categories from Newbook and aggregate by room type. Endpoint: site_list Returns list of unique room types (aggregated from individual sites): - id: Type name (used as ID since types don't have IDs) - name: Type name (e.g., "Standard Room", "Overflow") - type: Same as name - count: Number of sites/rooms of this type """ response = await self._request("site_list") # Log the raw response structure to understand it logger.info(f"site_list raw response type: {type(response)}") if isinstance(response, dict): logger.info(f"site_list response keys: {response.keys()}") items = response.get("data", response) if isinstance(response, dict) else response # Log first few items to understand structure if isinstance(items, list) and len(items) > 0: logger.info(f"site_list first item keys: {items[0].keys() if isinstance(items[0], dict) else 'not a dict'}") logger.info(f"site_list first 3 items: {items[:3]}") # Aggregate by room type name AND build category_id -> type mapping type_counts = {} # type_name -> count category_id_to_type = {} # category_id -> room_type (for booking filtering) if isinstance(items, list): for item in items: # Get category_id (what bookings use to identify room type) category_id = item.get("category_id") or item.get("site_category_id") # Try various field names that might contain the room type/category room_type = ( item.get("site_type") or item.get("type") or item.get("category") or item.get("category_name") or item.get("room_type") or item.get("site_category") or "" ) # Fall back to site_name if no type found (shouldn't happen normally) if not room_type: room_type = item.get("site_name", item.get("name", "Unknown")) logger.debug(f"No type field found, using site_name: {room_type}") if room_type: type_counts[room_type] = type_counts.get(room_type, 0) + 1 # Build mapping from category_id to type if category_id and room_type: category_id_to_type[str(category_id)] = room_type # Convert to list format categories = [] for type_name, count in sorted(type_counts.items()): categories.append({ "id": type_name, # Use type name as ID "name": type_name, "type": type_name, "count": count, }) logger.info(f"Fetched {len(categories)} unique room types from Newbook (from {sum(type_counts.values())} sites)") logger.info(f"Built category_id to type mapping: {category_id_to_type}") return categories, category_id_to_type def process_bookings_for_guests( self, bookings: list[dict], included_room_types: list[str] = None, category_id_to_type: dict[str, str] = None ) -> dict[str, int]: """ Process bookings to count total guests per stay date. Args: bookings: List of bookings from get_bookings() included_room_types: List of room type names to include (None = all) category_id_to_type: Mapping from category_id (e.g. "1") to room type (e.g. "Standard") Returns dict keyed by date string with guest count """ from datetime import datetime, timedelta guests_by_date = {} total_bookings_processed = 0 total_guests_counted = 0 bookings_filtered_out = 0 category_id_to_type = category_id_to_type or {} logger.info(f"Processing {len(bookings)} bookings for guest counts") if included_room_types: logger.info(f"Filtering to room types: {included_room_types}") if category_id_to_type: logger.info(f"Using category_id to type mapping: {category_id_to_type}") # Resolve room types for all bookings using the mapping resolved_room_types = set() for b in bookings: category_id = b.get("category_id", "") resolved_type = category_id_to_type.get(category_id, b.get("room_type", category_id)) resolved_room_types.add(resolved_type) logger.info(f"Resolved room types in bookings: {resolved_room_types}") # Log matching analysis if filtering if included_room_types: matching = resolved_room_types & set(included_room_types) non_matching = resolved_room_types - set(included_room_types) logger.info(f"Room type matching: {len(matching)} match, {len(non_matching)} don't match") if non_matching: logger.info(f"Non-matching room types: {non_matching}") for booking in bookings: # Get category_id and resolve to room type category_id = booking.get("category_id", "") room_type = category_id_to_type.get(category_id, booking.get("room_type", category_id)) # Filter by room type if specified if included_room_types and room_type not in included_room_types: bookings_filtered_out += 1 continue # Get guest count and stay dates # Don't use "or 1" - trust the computed value (0 is valid for infant-only bookings) num_guests = booking.get("num_guests", 0) check_in = booking.get("check_in_date") check_out = booking.get("check_out_date") nights = booking.get("nights", 1) if not check_in: continue total_bookings_processed += 1 total_guests_counted += num_guests # Parse check-in date if isinstance(check_in, str): try: check_in_date = datetime.fromisoformat(check_in.split("T")[0]).date() except ValueError: continue else: check_in_date = check_in # Calculate stay dates (guest is present from check-in through day before check-out) if check_out: if isinstance(check_out, str): try: check_out_date = datetime.fromisoformat(check_out.split("T")[0]).date() except ValueError: check_out_date = check_in_date + timedelta(days=nights or 1) else: check_out_date = check_out else: check_out_date = check_in_date + timedelta(days=nights or 1) # Add guests to each stay date (not including checkout day) current_date = check_in_date while current_date < check_out_date: date_str = current_date.isoformat() if date_str not in guests_by_date: guests_by_date[date_str] = 0 guests_by_date[date_str] += num_guests current_date += timedelta(days=1) logger.info(f"Guest count summary: processed {total_bookings_processed} bookings, {total_guests_counted} total guests, {bookings_filtered_out} filtered by room type") logger.info(f"Calculated guest counts for {len(guests_by_date)} dates") if guests_by_date: sample_dates = list(guests_by_date.items())[:3] logger.info(f"Sample guest counts: {sample_dates}") return guests_by_date def process_bookings_for_arrivals( self, bookings: list[dict], included_room_types: list[str] = None, category_id_to_type: dict[str, str] = None ) -> dict[str, dict]: """ Process bookings to extract arrival information per check-in date. Args: bookings: List of bookings from get_bookings() included_room_types: Optional room type filter (same as guest count filtering) category_id_to_type: Mapping from category_id to room type name Returns dict keyed by check-in date string with: { "2026-01-20": { "count": 6, "ids": ["12345", "12346", ...], "details": [ { "booking_id": "12345", "booking_reference": "NB-001", "num_guests": 2, "room_type": "Standard", "status": "confirmed" }, ... ] } } """ from datetime import datetime arrivals_by_date = {} category_id_to_type = category_id_to_type or {} logger.info(f"Processing {len(bookings)} bookings for arrival tracking") if included_room_types: logger.info(f"Filtering to room types: {included_room_types}") for booking in bookings: # Apply room type filter (consistent with guest count filtering) category_id = booking.get("category_id", "") room_type = category_id_to_type.get(category_id, booking.get("room_type", category_id)) if included_room_types and room_type not in included_room_types: continue # Skip cancelled bookings if booking.get("status", "").lower() == "cancelled": continue # Get check-in date (this is the arrival date) check_in = booking.get("check_in_date") if not check_in: continue # Parse to date string if isinstance(check_in, str): try: check_in_date = datetime.fromisoformat(check_in.split("T")[0]).date() except ValueError: continue else: check_in_date = check_in date_str = check_in_date.isoformat() # Initialize date entry if needed if date_str not in arrivals_by_date: arrivals_by_date[date_str] = { "count": 0, "ids": [], "details": [] } # Add arrival arrivals_by_date[date_str]["count"] += 1 arrivals_by_date[date_str]["ids"].append(str(booking.get("booking_id", ""))) arrivals_by_date[date_str]["details"].append({ "booking_id": str(booking.get("booking_id", "")), "booking_reference": booking.get("booking_reference", ""), "num_guests": booking.get("num_guests", 0), "room_type": room_type, "status": booking.get("status", "") }) logger.info(f"Found arrivals for {len(arrivals_by_date)} dates") if arrivals_by_date: sample = list(arrivals_by_date.items())[:3] logger.info(f"Sample arrivals: {sample}") return arrivals_by_date async def get_charges_list( self, date_from: date, date_to: date, account_for: str = None ) -> list[dict]: """ Fetch charges list from Newbook with pagination support. Endpoint: charges_list Args: date_from: Period start (charges raised or voided within this period) date_to: Period end account_for: Optional filter by account type (leads, guests, bookings, companies, travel_agents) Returns list of charges with: - id: Charge ID - gl_account_id: GL Account ID - gl_account_code: GL Account code - description: Charge description (e.g., "Ticket: 22900 - 1 x Venison Bourguignon") - amount_ex_tax: Net amount (exc tax) - amount_inc_tax: Gross amount (inc tax) - generated_when: When the charge was created - voided_when: When the charge was voided (None if not voided) - voided_by: Who voided it ("0" if not voided) """ charges = [] data_offset = 0 data_limit = 1000 # Max allowed by Newbook total_fetched = 0 while True: payload = { "period_from": f"{date_from.isoformat()} 00:00:00", "period_to": f"{date_to.isoformat()} 23:59:59", "data_offset": data_offset, "data_limit": data_limit, } if account_for: payload["account_for"] = account_for logger.info(f"Fetching charges: {date_from} to {date_to}, offset={data_offset}, limit={data_limit}") response = await self._request("charges_list", payload) # Get pagination info from response data_total = response.get("data_total", 0) data_count = response.get("data_count", 0) items = response.get("data", response) if isinstance(response, dict) else response if isinstance(items, list): for item in items: charges.append({ "id": item.get("id"), "account_id": item.get("account_id"), "account_for": item.get("account_for"), "gl_account_id": str(item.get("gl_account_id", "")), "gl_account_code": item.get("gl_account_code", ""), "gl_category_id": item.get("gl_category_id"), "description": item.get("description", ""), "amount": Decimal(str(item.get("amount", 0) or 0)), "amount_ex_tax": Decimal(str(item.get("amount_ex_tax", 0) or 0)), "amount_inc_tax": Decimal(str(item.get("amount_inc_tax", 0) or 0)), "tax": Decimal(str(item.get("tax", 0) or 0)), "generated_when": item.get("generated_when"), "voided_when": item.get("voided_when"), "voided_by": str(item.get("voided_by", "0")), }) total_fetched += len(items) logger.info(f"Charges page: got {data_count} items, total available: {data_total}, fetched so far: {total_fetched}") # Check if we've fetched all records if data_count == 0 or total_fetched >= data_total or len(items) < data_limit: break # Move to next page data_offset += data_limit logger.info(f"Fetched {len(charges)} total charges from Newbook ({date_from} to {date_to})") return charges