Stack-wide NewBook config lives in the Settings app (LXC 116) and is fetched live via SETTINGS_URL/SETTINGS_SECRET — same pattern as cashup, room-planner and maintenance. App-local system_config credentials remain as a fallback for standalone/dev use. The app's Settings → Newbook page no longer edits credentials; it points to the central app and keeps Test Connection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
453 lines
15 KiB
Python
453 lines
15 KiB
Python
"""
|
|
Newbook API Client
|
|
|
|
CRITICAL: This client is READ-ONLY.
|
|
Newbook API uses POST for all requests - the "action" parameter determines the operation.
|
|
This client ONLY uses read actions (bookings_list, site_list, report_*).
|
|
NO write actions (booking_create, booking_update, booking_cancel, etc.) are used.
|
|
Data flows ONE WAY: Newbook → Local Database
|
|
"""
|
|
import os
|
|
import httpx
|
|
import asyncio
|
|
import logging
|
|
from datetime import date, timedelta
|
|
from typing import Optional, List
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class NewbookAPIError(Exception):
|
|
"""Custom exception for Newbook API errors"""
|
|
pass
|
|
|
|
|
|
class NewbookClient:
|
|
"""
|
|
Async client for Newbook REST API
|
|
|
|
Rate limiting: ~100 requests/min, using 0.75s delay between requests
|
|
Pagination: Uses data_offset/data_limit, max 1000 per request
|
|
"""
|
|
|
|
BASE_URL = "https://api.newbook.cloud/rest"
|
|
|
|
def __init__(self, api_key: str = None, username: str = None, password: str = None, region: str = None):
|
|
# Use provided credentials or fall back to environment variables
|
|
self.api_key = api_key or os.getenv("NEWBOOK_API_KEY")
|
|
self.username = username or os.getenv("NEWBOOK_USERNAME")
|
|
self.password = password or os.getenv("NEWBOOK_PASSWORD")
|
|
self.region = region or os.getenv("NEWBOOK_REGION")
|
|
|
|
if not all([self.api_key, self.username, self.password, self.region]):
|
|
logger.warning("Newbook credentials not fully configured")
|
|
|
|
def _get_url(self, endpoint: str) -> str:
|
|
"""Get full URL for an endpoint"""
|
|
return f"{self.BASE_URL}/{endpoint}"
|
|
|
|
@classmethod
|
|
async def from_db(cls, db):
|
|
"""
|
|
Create client with credentials from the central Settings service
|
|
(stack-wide NewBook config), falling back to the app-local
|
|
system_config table for standalone/dev use.
|
|
"""
|
|
from services.central_settings import get_newbook_credentials
|
|
|
|
central = await get_newbook_credentials()
|
|
if central:
|
|
return cls(**central)
|
|
|
|
from api.config import _get_config_value
|
|
|
|
api_key = await _get_config_value(db, "newbook_api_key")
|
|
username = await _get_config_value(db, "newbook_username")
|
|
password = await _get_config_value(db, "newbook_password")
|
|
region = await _get_config_value(db, "newbook_region")
|
|
|
|
return cls(api_key=api_key, username=username, password=password, region=region)
|
|
|
|
async def __aenter__(self):
|
|
self.client = httpx.AsyncClient(timeout=300.0)
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
await self.client.aclose()
|
|
|
|
def _get_auth_payload(self) -> dict:
|
|
"""Get base authentication payload (api_key and region only - username/password go in Basic Auth)"""
|
|
return {
|
|
"api_key": self.api_key,
|
|
"region": self.region
|
|
}
|
|
|
|
async def test_connection(self) -> bool:
|
|
"""Test API connection"""
|
|
try:
|
|
payload = self._get_auth_payload()
|
|
|
|
response = await self.client.post(
|
|
self._get_url("site_list"),
|
|
json=payload,
|
|
auth=(self.username, self.password)
|
|
)
|
|
return response.status_code == 200
|
|
except Exception as e:
|
|
logger.error(f"Newbook connection test failed: {e}")
|
|
return False
|
|
|
|
async def get_bookings(
|
|
self,
|
|
modified_since: Optional[str] = None,
|
|
modified_until: Optional[str] = None,
|
|
batch_size: int = 1000
|
|
) -> List[dict]:
|
|
"""
|
|
Fetch all bookings with pagination and rate limiting.
|
|
|
|
Uses list_type="all" which returns all bookings (including cancelled).
|
|
period_from/period_to filter by created/modified timestamp, not stay dates.
|
|
|
|
Args:
|
|
modified_since: ISO timestamp - only bookings created/modified after this
|
|
modified_until: ISO timestamp - only bookings created/modified before this
|
|
batch_size: Records per request (max 1000)
|
|
|
|
Returns:
|
|
List of booking objects (all statuses including cancelled)
|
|
"""
|
|
all_bookings = []
|
|
offset = 0
|
|
|
|
while True:
|
|
logger.info(f"Fetching Newbook bookings (all): modified_since={modified_since} (offset: {offset})")
|
|
|
|
payload = self._get_auth_payload()
|
|
payload.update({
|
|
"list_type": "all",
|
|
"data_offset": offset,
|
|
"data_limit": batch_size
|
|
})
|
|
|
|
# Add optional timestamp filters
|
|
if modified_since:
|
|
payload["period_from"] = modified_since
|
|
if modified_until:
|
|
payload["period_to"] = modified_until
|
|
|
|
response = await self.client.post(
|
|
self._get_url("bookings_list"),
|
|
json=payload,
|
|
auth=(self.username, self.password)
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.error(f"Newbook API error {response.status_code}: {response.text}")
|
|
raise NewbookAPIError(f"Failed to fetch bookings: {response.status_code}")
|
|
|
|
data = response.json()
|
|
|
|
if not data.get("success"):
|
|
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
|
|
|
|
bookings = data.get("data", [])
|
|
|
|
if not bookings:
|
|
break
|
|
|
|
all_bookings.extend(bookings)
|
|
logger.info(f"Fetched {len(bookings)} bookings (offset {offset})")
|
|
|
|
# Check if we've got all records
|
|
total = data.get("data_total", 0)
|
|
if offset + len(bookings) >= total:
|
|
break
|
|
|
|
offset += batch_size
|
|
|
|
# Rate limiting: 0.75s delay
|
|
await asyncio.sleep(0.75)
|
|
|
|
logger.info(f"Total bookings fetched: {len(all_bookings)}")
|
|
return all_bookings
|
|
|
|
async def get_bookings_by_stay_dates(
|
|
self,
|
|
from_date: date,
|
|
to_date: date,
|
|
list_type: str = "staying",
|
|
batch_size: int = 1000
|
|
) -> List[dict]:
|
|
"""
|
|
Fetch bookings by stay dates (arrival/departure/staying period).
|
|
|
|
Args:
|
|
from_date: Start date for stay period
|
|
to_date: End date for stay period
|
|
list_type: Type of booking list:
|
|
"staying" - bookings staying during dates (excludes cancelled)
|
|
"arrived" - arrived during dates (add mode="projected" for expected)
|
|
"arriving" - expected to arrive before period_to
|
|
"departed" - departed during dates
|
|
"departing" - expected to depart during dates
|
|
"cancelled" - cancelled during dates
|
|
"placed" - created during dates
|
|
"no_show" - no shows for dates
|
|
batch_size: Records per request (max 1000)
|
|
|
|
Returns:
|
|
List of booking objects
|
|
"""
|
|
all_bookings = []
|
|
offset = 0
|
|
|
|
while True:
|
|
logger.info(f"Fetching Newbook bookings ({list_type}): {from_date} to {to_date} (offset: {offset})")
|
|
|
|
payload = self._get_auth_payload()
|
|
payload.update({
|
|
"list_type": list_type,
|
|
"period_from": from_date.isoformat(),
|
|
"period_to": to_date.isoformat(),
|
|
"data_offset": offset,
|
|
"data_limit": batch_size
|
|
})
|
|
|
|
response = await self.client.post(
|
|
self._get_url("bookings_list"),
|
|
json=payload,
|
|
auth=(self.username, self.password)
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.error(f"Newbook API error {response.status_code}: {response.text}")
|
|
raise NewbookAPIError(f"Failed to fetch bookings: {response.status_code}")
|
|
|
|
data = response.json()
|
|
|
|
if not data.get("success"):
|
|
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
|
|
|
|
bookings = data.get("data", [])
|
|
|
|
if not bookings:
|
|
break
|
|
|
|
all_bookings.extend(bookings)
|
|
logger.info(f"Fetched {len(bookings)} bookings (offset {offset})")
|
|
|
|
# Check if we've got all records
|
|
total = data.get("data_total", 0)
|
|
if offset + len(bookings) >= total:
|
|
break
|
|
|
|
offset += batch_size
|
|
|
|
# Rate limiting: 0.75s delay
|
|
await asyncio.sleep(0.75)
|
|
|
|
logger.info(f"Total bookings fetched: {len(all_bookings)}")
|
|
return all_bookings
|
|
|
|
async def get_occupancy_report(
|
|
self,
|
|
from_date: date,
|
|
to_date: date
|
|
) -> List[dict]:
|
|
"""
|
|
Fetch occupancy report for date range.
|
|
|
|
Uses reports_occupancy endpoint which returns data by room category.
|
|
Returns all categories with nested occupancy data for each date in range.
|
|
No pagination needed - API returns full dataset in single response.
|
|
|
|
Response format:
|
|
[
|
|
{
|
|
"category_id": "1",
|
|
"category_name": "Single Room",
|
|
"occupancy": {
|
|
"2024-08-01": {
|
|
"date": "2024-08-01",
|
|
"available": 5,
|
|
"occupied": 3,
|
|
"maintenance": 1,
|
|
"allotted": 0,
|
|
"revenue_gross": 450.00,
|
|
"revenue_net": 375.00
|
|
},
|
|
...
|
|
}
|
|
},
|
|
...
|
|
]
|
|
|
|
Returns list of category objects with nested occupancy by date
|
|
"""
|
|
logger.info(f"Fetching occupancy report: {from_date} to {to_date}")
|
|
|
|
payload = self._get_auth_payload()
|
|
payload.update({
|
|
"period_from": f"{from_date.isoformat()} 00:00:00",
|
|
"period_to": f"{to_date.isoformat()} 23:59:59"
|
|
})
|
|
|
|
response = await self.client.post(
|
|
self._get_url("reports_occupancy"),
|
|
json=payload,
|
|
auth=(self.username, self.password)
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
raise NewbookAPIError(f"Failed to fetch occupancy: {response.status_code}")
|
|
|
|
data = response.json()
|
|
|
|
if not data.get("success"):
|
|
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
|
|
|
|
records = data.get("data", [])
|
|
logger.info(f"Fetched occupancy report: {len(records)} categories")
|
|
return records
|
|
|
|
async def get_site_list(self) -> List[dict]:
|
|
"""Fetch list of rooms/sites with categories"""
|
|
payload = self._get_auth_payload()
|
|
|
|
response = await self.client.post(
|
|
self._get_url("site_list"),
|
|
json=payload,
|
|
auth=(self.username, self.password)
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
raise NewbookAPIError(f"Failed to fetch site list: {response.status_code}")
|
|
|
|
data = response.json()
|
|
return data.get("data", [])
|
|
|
|
async def get_earned_revenue(
|
|
self,
|
|
from_date: date,
|
|
to_date: date
|
|
) -> dict:
|
|
"""
|
|
Fetch earned revenue report day by day
|
|
|
|
Returns dict keyed by date with revenue breakdown by GL code
|
|
"""
|
|
all_revenue = {}
|
|
current_date = from_date
|
|
|
|
while current_date <= to_date:
|
|
logger.info(f"Fetching earned revenue for {current_date}")
|
|
|
|
payload = self._get_auth_payload()
|
|
payload.update({
|
|
"period_from": current_date.isoformat(),
|
|
"period_to": current_date.isoformat()
|
|
})
|
|
|
|
response = await self.client.post(
|
|
self._get_url("reports_earned_revenue"),
|
|
json=payload,
|
|
auth=(self.username, self.password)
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
if data.get("success"):
|
|
day_data = data.get("data", {})
|
|
# Debug: log first day's response structure
|
|
if current_date == from_date:
|
|
import json
|
|
logger.info(f"Sample earned revenue response: {json.dumps(day_data)[:500]}")
|
|
all_revenue[current_date.isoformat()] = day_data
|
|
|
|
current_date += timedelta(days=1)
|
|
|
|
# Rate limiting
|
|
await asyncio.sleep(0.75)
|
|
|
|
return all_revenue
|
|
|
|
async def get_transaction_flow(
|
|
self,
|
|
from_date: date,
|
|
to_date: date,
|
|
batch_size: int = 5000
|
|
) -> List[dict]:
|
|
"""
|
|
Fetch transaction flow report for date range.
|
|
Used by reconciliation module for payment categorization.
|
|
|
|
Returns raw transaction records (payments, refunds, voided items).
|
|
Excludes balance_transfer items.
|
|
Handles pagination via data_offset/data_limit.
|
|
"""
|
|
logger.info(f"Fetching transaction flow: {from_date} to {to_date}")
|
|
|
|
all_transactions = []
|
|
offset = 0
|
|
|
|
while True:
|
|
payload = self._get_auth_payload()
|
|
payload.update({
|
|
"period_from": f"{from_date.isoformat()} 00:00:00",
|
|
"period_to": f"{to_date.isoformat()} 23:59:59",
|
|
"data_offset": offset,
|
|
"data_limit": batch_size
|
|
})
|
|
|
|
response = await self.client.post(
|
|
self._get_url("reports_transaction_flow"),
|
|
json=payload,
|
|
auth=(self.username, self.password)
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
raise NewbookAPIError(f"Failed to fetch transaction flow: {response.status_code}")
|
|
|
|
data = response.json()
|
|
if not data.get("success"):
|
|
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
|
|
|
|
records = data.get("data", [])
|
|
all_transactions.extend(records)
|
|
|
|
# If we got fewer records than the limit, we're done
|
|
if len(records) < batch_size:
|
|
break
|
|
|
|
offset += batch_size
|
|
await asyncio.sleep(0.75)
|
|
|
|
logger.info(f"Fetched transaction flow: {len(all_transactions)} transactions")
|
|
return all_transactions
|
|
|
|
async def get_gl_account_list(self) -> List[dict]:
|
|
"""
|
|
Fetch GL account list from Newbook.
|
|
Used for reconciliation sales breakdown column configuration.
|
|
"""
|
|
logger.info("Fetching GL account list from Newbook")
|
|
|
|
payload = self._get_auth_payload()
|
|
|
|
response = await self.client.post(
|
|
self._get_url("gl_account_list"),
|
|
json=payload,
|
|
auth=(self.username, self.password)
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
raise NewbookAPIError(f"Failed to fetch GL accounts: {response.status_code}")
|
|
|
|
data = response.json()
|
|
if not data.get("success"):
|
|
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
|
|
|
|
records = data.get("data", [])
|
|
logger.info(f"Fetched {len(records)} GL accounts")
|
|
return records
|