Files
bakery-ia/services/pos/app/integrations/square_client.py

463 lines
17 KiB
Python

# services/pos/app/integrations/square_client.py
"""
Square POS Client
Integration with Square Point of Sale API
"""
import hashlib
import hmac
import json
from typing import Dict, List, Optional, Any, Tuple
from datetime import datetime
import asyncio
import httpx
import structlog
from .base_pos_client import (
BasePOSClient,
POSCredentials,
POSTransaction,
POSTransactionItem,
POSProduct,
POSClientError,
POSAuthenticationError,
POSRateLimitError,
POSConnectionError
)
logger = structlog.get_logger()
class SquarePOSClient(BasePOSClient):
"""Square POS API client implementation"""
def __init__(self, credentials: POSCredentials):
super().__init__(credentials)
self.base_url = self._get_base_url()
self.application_id = credentials.application_id
self.access_token = credentials.access_token
self.webhook_secret = credentials.webhook_secret
self.location_id = credentials.location_id
if not self.access_token:
raise POSAuthenticationError("Square access token is required")
def _get_base_url(self) -> str:
"""Get Square API base URL based on environment"""
if self.credentials.environment.lower() == "production":
return "https://connect.squareup.com"
else:
return "https://connect.squareupsandbox.com"
def _get_headers(self) -> Dict[str, str]:
"""Get headers for Square API requests"""
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if self.application_id:
headers["Square-Version"] = "2024-01-18" # Use latest API version
return headers
async def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None,
params: Optional[Dict] = None
) -> Dict[str, Any]:
"""Make HTTP request to Square API with error handling"""
url = f"{self.base_url}{endpoint}"
headers = self._get_headers()
start_time = datetime.utcnow()
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request(
method=method,
url=url,
headers=headers,
json=data,
params=params
)
duration_ms = int((datetime.utcnow() - start_time).total_seconds() * 1000)
self.log_api_call(method, endpoint, response.status_code, duration_ms)
if response.status_code == 401:
raise POSAuthenticationError("Invalid Square access token")
elif response.status_code == 429:
raise POSRateLimitError("Square API rate limit exceeded")
elif response.status_code >= 400:
error_text = response.text
raise POSClientError(f"Square API error {response.status_code}: {error_text}")
return response.json()
except httpx.TimeoutException:
raise POSConnectionError("Timeout connecting to Square API")
except httpx.ConnectError:
raise POSConnectionError("Failed to connect to Square API")
async def test_connection(self) -> Tuple[bool, str]:
"""Test connection to Square API"""
try:
# Try to get location info
response = await self._make_request("GET", "/v2/locations")
locations = response.get("locations", [])
if locations:
return True, f"Connected successfully. Found {len(locations)} location(s)."
else:
return False, "Connected but no locations found"
except POSAuthenticationError:
return False, "Authentication failed - invalid access token"
except POSRateLimitError:
return False, "Rate limit exceeded"
except POSConnectionError as e:
return False, f"Connection failed: {str(e)}"
except Exception as e:
return False, f"Test failed: {str(e)}"
async def get_transactions(
self,
start_date: datetime,
end_date: datetime,
location_id: Optional[str] = None,
limit: int = 100,
cursor: Optional[str] = None
) -> Tuple[List[POSTransaction], Optional[str]]:
"""Get transactions from Square API"""
# Use provided location_id or fall back to configured one
target_location = location_id or self.location_id
if not target_location:
# Get first available location
locations_response = await self._make_request("GET", "/v2/locations")
locations = locations_response.get("locations", [])
if not locations:
return [], None
target_location = locations[0]["id"]
# Build query parameters
query = {
"location_ids": [target_location],
"begin_time": start_date.isoformat() + "Z",
"end_time": end_date.isoformat() + "Z",
"limit": min(limit, 200), # Square max is 200
}
if cursor:
query["cursor"] = cursor
try:
response = await self._make_request("POST", "/v2/orders/search", data={"query": query})
orders = response.get("orders", [])
transactions = []
for order in orders:
transaction = self._parse_square_order(order)
if transaction:
transactions.append(transaction)
next_cursor = response.get("cursor")
return transactions, next_cursor
except Exception as e:
self.log_error(e, "Getting transactions")
raise
async def get_transaction(self, transaction_id: str) -> Optional[POSTransaction]:
"""Get specific transaction by ID"""
try:
response = await self._make_request("GET", f"/v2/orders/{transaction_id}")
order = response.get("order")
if order:
return self._parse_square_order(order)
return None
except Exception as e:
self.log_error(e, f"Getting transaction {transaction_id}")
return None
def _parse_square_order(self, order: Dict[str, Any]) -> Optional[POSTransaction]:
"""Parse Square order into standardized transaction"""
try:
# Extract basic transaction info
external_id = order.get("id", "")
state = order.get("state", "")
# Map Square states to our standard states
status_map = {
"COMPLETED": "completed",
"CANCELED": "voided",
"DRAFT": "pending",
"OPEN": "pending"
}
status = status_map.get(state, "pending")
# Parse amounts (Square uses smallest currency unit, e.g., cents)
total_money = order.get("total_money", {})
total_amount = float(total_money.get("amount", 0)) / 100.0
base_price_money = order.get("base_price_money", {})
subtotal = float(base_price_money.get("amount", 0)) / 100.0
total_tax_money = order.get("total_tax_money", {})
tax_amount = float(total_tax_money.get("amount", 0)) / 100.0
total_tip_money = order.get("total_tip_money", {})
tip_amount = float(total_tip_money.get("amount", 0)) / 100.0
total_discount_money = order.get("total_discount_money", {})
discount_amount = float(total_discount_money.get("amount", 0)) / 100.0
currency = total_money.get("currency", "USD")
# Parse timestamps
created_at = order.get("created_at")
transaction_date = datetime.fromisoformat(created_at.replace("Z", "+00:00")) if created_at else datetime.utcnow()
# Parse location info
location_id = order.get("location_id")
# Parse line items
items = []
line_items = order.get("line_items", [])
for line_item in line_items:
item = self._parse_square_line_item(line_item)
if item:
items.append(item)
# Parse payments for payment method
payment_method = None
tenders = order.get("tenders", [])
if tenders:
payment_method = tenders[0].get("type", "").lower()
# Create transaction
transaction = POSTransaction(
external_id=external_id,
transaction_type="sale", # Square orders are typically sales
status=status,
total_amount=total_amount,
subtotal=subtotal,
tax_amount=tax_amount,
tip_amount=tip_amount,
discount_amount=discount_amount,
currency=currency,
transaction_date=transaction_date,
payment_method=payment_method,
payment_status="paid" if status == "completed" else "pending",
location_id=location_id,
items=items,
raw_data=order
)
return transaction
except Exception as e:
self.log_error(e, f"Parsing Square order {order.get('id', 'unknown')}")
return None
def _parse_square_line_item(self, line_item: Dict[str, Any]) -> Optional[POSTransactionItem]:
"""Parse Square line item into standardized transaction item"""
try:
name = line_item.get("name", "Unknown Item")
quantity = float(line_item.get("quantity", "1"))
# Parse pricing
item_total_money = line_item.get("item_total_money", {})
total_price = float(item_total_money.get("amount", 0)) / 100.0
unit_price = total_price / quantity if quantity > 0 else 0
# Parse variations for SKU
variation = line_item.get("catalog_object_id")
sku = variation if variation else None
# Parse category from item data
item_data = line_item.get("item_data", {})
category = item_data.get("category_name")
# Parse modifiers
modifiers_data = line_item.get("modifiers", [])
modifiers = {}
for modifier in modifiers_data:
mod_name = modifier.get("name", "")
mod_price = float(modifier.get("total_price_money", {}).get("amount", 0)) / 100.0
modifiers[mod_name] = mod_price
item = POSTransactionItem(
external_id=line_item.get("uid"),
sku=sku,
name=name,
category=category,
quantity=quantity,
unit_price=unit_price,
total_price=total_price,
discount_amount=0, # Square handles discounts at order level
tax_amount=0, # Square handles taxes at order level
modifiers=modifiers if modifiers else None,
raw_data=line_item
)
return item
except Exception as e:
self.log_error(e, f"Parsing Square line item {line_item.get('uid', 'unknown')}")
return None
async def get_products(
self,
location_id: Optional[str] = None,
limit: int = 100,
cursor: Optional[str] = None
) -> Tuple[List[POSProduct], Optional[str]]:
"""Get products from Square Catalog API"""
query_params = {
"types": "ITEM",
"limit": min(limit, 1000) # Square catalog max
}
if cursor:
query_params["cursor"] = cursor
try:
response = await self._make_request("GET", "/v2/catalog/list", params=query_params)
objects = response.get("objects", [])
products = []
for obj in objects:
product = self._parse_square_catalog_item(obj)
if product:
products.append(product)
next_cursor = response.get("cursor")
return products, next_cursor
except Exception as e:
self.log_error(e, "Getting products")
raise
def _parse_square_catalog_item(self, catalog_object: Dict[str, Any]) -> Optional[POSProduct]:
"""Parse Square catalog item into standardized product"""
try:
item_data = catalog_object.get("item_data", {})
external_id = catalog_object.get("id", "")
name = item_data.get("name", "Unknown Product")
description = item_data.get("description")
category = item_data.get("category_name")
is_active = not catalog_object.get("is_deleted", False)
# Get price from first variation
variations = item_data.get("variations", [])
price = 0.0
sku = None
if variations:
first_variation = variations[0]
variation_data = first_variation.get("item_variation_data", {})
price_money = variation_data.get("price_money", {})
price = float(price_money.get("amount", 0)) / 100.0
sku = variation_data.get("sku")
product = POSProduct(
external_id=external_id,
name=name,
sku=sku,
category=category,
subcategory=None,
price=price,
description=description,
is_active=is_active,
raw_data=catalog_object
)
return product
except Exception as e:
self.log_error(e, f"Parsing Square catalog item {catalog_object.get('id', 'unknown')}")
return None
def verify_webhook_signature(self, payload: bytes, signature: str) -> bool:
"""Verify Square webhook signature"""
if not self.webhook_secret:
self.logger.warning("No webhook secret configured for signature verification")
return True # Allow webhooks without verification if no secret
try:
# Square uses HMAC-SHA256
expected_signature = hmac.new(
self.webhook_secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
# Remove any prefix from signature
clean_signature = signature.replace("sha256=", "")
return hmac.compare_digest(expected_signature, clean_signature)
except Exception as e:
self.log_error(e, "Webhook signature verification")
return False
def parse_webhook_payload(self, payload: Dict[str, Any]) -> Optional[POSTransaction]:
"""Parse Square webhook payload"""
try:
event_type = payload.get("type")
# Handle different Square webhook events
if event_type in ["order.created", "order.updated", "order.fulfilled"]:
order_data = payload.get("data", {}).get("object", {}).get("order")
if order_data:
return self._parse_square_order(order_data)
elif event_type in ["payment.created", "payment.updated"]:
# For payment events, we might need to fetch the full order
payment_data = payload.get("data", {}).get("object", {}).get("payment", {})
order_id = payment_data.get("order_id")
if order_id:
# Note: This would require an async call, so this is a simplified version
self.logger.info("Payment webhook received", order_id=order_id, event_type=event_type)
return None
except Exception as e:
self.log_error(e, "Parsing webhook payload")
return None
def get_webhook_events(self) -> List[str]:
"""Get list of supported Square webhook events"""
return [
"order.created",
"order.updated",
"order.fulfilled",
"payment.created",
"payment.updated",
"inventory.count.updated"
]
def get_rate_limits(self) -> Dict[str, Any]:
"""Get Square API rate limit information"""
return {
"requests_per_second": 100,
"daily_limit": 50000,
"burst_limit": 200,
"webhook_limit": 1000
}