463 lines
17 KiB
Python
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
|
||
|
|
}
|