Files
bakery-ia/services/pos/app/consumers/pos_event_consumer.py
2025-12-05 20:07:01 +01:00

584 lines
22 KiB
Python

"""
POS Event Consumer
Processes POS webhook events from RabbitMQ queue
Handles sales transactions, refunds, and inventory updates from various POS systems
"""
import json
import structlog
from typing import Dict, Any, Optional
from datetime import datetime
from decimal import Decimal
from shared.messaging import RabbitMQClient
from app.services.webhook_service import WebhookService
from sqlalchemy.ext.asyncio import AsyncSession
logger = structlog.get_logger()
class POSEventConsumer:
"""
Consumes POS webhook events from RabbitMQ and processes them
Supports multiple POS systems: Square, Shopify, Toast, etc.
"""
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
self.webhook_service = WebhookService()
async def consume_pos_events(
self,
rabbitmq_client: RabbitMQClient
):
"""
Start consuming POS events from RabbitMQ
"""
async def process_message(message):
"""Process a single POS event message"""
try:
async with message.process():
# Parse event data
event_data = json.loads(message.body.decode())
logger.info(
"Received POS event",
event_id=event_data.get('event_id'),
event_type=event_data.get('event_type'),
pos_system=event_data.get('data', {}).get('pos_system')
)
# Process the event
await self.process_pos_event(event_data)
except Exception as e:
logger.error(
"Error processing POS event",
error=str(e),
exc_info=True
)
# Start consuming events
await rabbitmq_client.consume_events(
exchange_name="pos.events",
queue_name="pos.processing.queue",
routing_key="pos.*",
callback=process_message
)
logger.info("Started consuming POS events")
async def process_pos_event(self, event_data: Dict[str, Any]) -> bool:
"""
Process a POS event based on type
Args:
event_data: Full event payload from RabbitMQ
Returns:
bool: True if processed successfully
"""
try:
data = event_data.get('data', {})
webhook_log_id = data.get('webhook_log_id')
pos_system = data.get('pos_system', 'unknown')
webhook_type = data.get('webhook_type')
payload = data.get('payload', {})
tenant_id = event_data.get('tenant_id')
if not webhook_log_id:
logger.warning("POS event missing webhook_log_id", event_data=event_data)
return False
# Update webhook log status to processing
await self.webhook_service.update_webhook_status(
webhook_log_id,
status="processing",
notes="Event consumer processing"
)
# Route to appropriate handler based on webhook type
success = False
if webhook_type in ['sale.completed', 'transaction.completed', 'order.completed']:
success = await self._handle_sale_completed(tenant_id, pos_system, payload)
elif webhook_type in ['sale.refunded', 'transaction.refunded', 'order.refunded']:
success = await self._handle_sale_refunded(tenant_id, pos_system, payload)
elif webhook_type in ['inventory.updated', 'stock.updated']:
success = await self._handle_inventory_updated(tenant_id, pos_system, payload)
else:
logger.warning("Unknown POS webhook type", webhook_type=webhook_type)
success = True # Mark as processed to avoid retry
# Update webhook log with final status
if success:
await self.webhook_service.update_webhook_status(
webhook_log_id,
status="completed",
notes="Successfully processed"
)
logger.info(
"POS event processed successfully",
webhook_log_id=webhook_log_id,
webhook_type=webhook_type
)
else:
await self.webhook_service.update_webhook_status(
webhook_log_id,
status="failed",
notes="Processing failed"
)
logger.error(
"POS event processing failed",
webhook_log_id=webhook_log_id,
webhook_type=webhook_type
)
return success
except Exception as e:
logger.error(
"Error in process_pos_event",
error=str(e),
event_id=event_data.get('event_id'),
exc_info=True
)
return False
async def _handle_sale_completed(
self,
tenant_id: str,
pos_system: str,
payload: Dict[str, Any]
) -> bool:
"""
Handle completed sale transaction
Updates:
- Inventory quantities (decrease stock)
- Sales analytics data
- Revenue tracking
Args:
tenant_id: Tenant ID
pos_system: POS system name (square, shopify, toast, etc.)
payload: Sale data from POS system
Returns:
bool: True if handled successfully
"""
try:
# Extract transaction data based on POS system format
transaction_data = self._parse_sale_data(pos_system, payload)
if not transaction_data:
logger.warning("Failed to parse sale data", pos_system=pos_system)
return False
# Update inventory via inventory service client
from shared.clients.inventory_client import InventoryServiceClient
from shared.config.base import get_settings
config = get_settings()
inventory_client = InventoryServiceClient(config, "pos")
for item in transaction_data.get('items', []):
product_id = item.get('product_id')
quantity = item.get('quantity', 0)
unit_of_measure = item.get('unit_of_measure', 'units')
if not product_id or quantity <= 0:
continue
# Decrease inventory stock
try:
await inventory_client.adjust_stock(
tenant_id=tenant_id,
product_id=product_id,
quantity=-quantity, # Negative for sale
unit_of_measure=unit_of_measure,
reason=f"POS sale - {pos_system}",
reference_id=transaction_data.get('transaction_id')
)
logger.info(
"Inventory updated for sale",
product_id=product_id,
quantity=quantity,
pos_system=pos_system
)
except Exception as inv_error:
logger.error(
"Failed to update inventory",
product_id=product_id,
error=str(inv_error)
)
# Continue processing other items even if one fails
# Publish sales data to sales service via RabbitMQ
from shared.messaging import get_rabbitmq_client
import uuid
rabbitmq_client = get_rabbitmq_client()
if rabbitmq_client:
sales_event = {
"event_id": str(uuid.uuid4()),
"event_type": "sales.transaction.completed",
"timestamp": datetime.utcnow().isoformat(),
"tenant_id": tenant_id,
"data": {
"transaction_id": transaction_data.get('transaction_id'),
"pos_system": pos_system,
"total_amount": transaction_data.get('total_amount', 0),
"items": transaction_data.get('items', []),
"payment_method": transaction_data.get('payment_method'),
"transaction_date": transaction_data.get('transaction_date'),
"customer_id": transaction_data.get('customer_id')
}
}
await rabbitmq_client.publish_event(
exchange_name="sales.events",
routing_key="sales.transaction.completed",
event_data=sales_event
)
logger.info(
"Published sales event",
event_id=sales_event["event_id"],
transaction_id=transaction_data.get('transaction_id')
)
return True
except Exception as e:
logger.error(
"Error handling sale completed",
error=str(e),
pos_system=pos_system,
exc_info=True
)
return False
async def _handle_sale_refunded(
self,
tenant_id: str,
pos_system: str,
payload: Dict[str, Any]
) -> bool:
"""
Handle refunded sale transaction
Updates:
- Inventory quantities (increase stock)
- Sales analytics (negative transaction)
Args:
tenant_id: Tenant ID
pos_system: POS system name
payload: Refund data from POS system
Returns:
bool: True if handled successfully
"""
try:
# Extract refund data based on POS system format
refund_data = self._parse_refund_data(pos_system, payload)
if not refund_data:
logger.warning("Failed to parse refund data", pos_system=pos_system)
return False
# Update inventory via inventory service client
from shared.clients.inventory_client import InventoryServiceClient
from shared.config.base import get_settings
config = get_settings()
inventory_client = InventoryServiceClient(config, "pos")
for item in refund_data.get('items', []):
product_id = item.get('product_id')
quantity = item.get('quantity', 0)
unit_of_measure = item.get('unit_of_measure', 'units')
if not product_id or quantity <= 0:
continue
# Increase inventory stock (return to stock)
try:
await inventory_client.adjust_stock(
tenant_id=tenant_id,
product_id=product_id,
quantity=quantity, # Positive for refund
unit_of_measure=unit_of_measure,
reason=f"POS refund - {pos_system}",
reference_id=refund_data.get('refund_id')
)
logger.info(
"Inventory updated for refund",
product_id=product_id,
quantity=quantity,
pos_system=pos_system
)
except Exception as inv_error:
logger.error(
"Failed to update inventory for refund",
product_id=product_id,
error=str(inv_error)
)
# Publish refund event to sales service
from shared.messaging import get_rabbitmq_client
import uuid
rabbitmq_client = get_rabbitmq_client()
if rabbitmq_client:
refund_event = {
"event_id": str(uuid.uuid4()),
"event_type": "sales.transaction.refunded",
"timestamp": datetime.utcnow().isoformat(),
"tenant_id": tenant_id,
"data": {
"refund_id": refund_data.get('refund_id'),
"original_transaction_id": refund_data.get('original_transaction_id'),
"pos_system": pos_system,
"refund_amount": refund_data.get('refund_amount', 0),
"items": refund_data.get('items', []),
"refund_date": refund_data.get('refund_date')
}
}
await rabbitmq_client.publish_event(
exchange_name="sales.events",
routing_key="sales.transaction.refunded",
event_data=refund_event
)
logger.info(
"Published refund event",
event_id=refund_event["event_id"],
refund_id=refund_data.get('refund_id')
)
return True
except Exception as e:
logger.error(
"Error handling sale refunded",
error=str(e),
pos_system=pos_system,
exc_info=True
)
return False
async def _handle_inventory_updated(
self,
tenant_id: str,
pos_system: str,
payload: Dict[str, Any]
) -> bool:
"""
Handle inventory update from POS system
Syncs inventory levels from POS to our system
Args:
tenant_id: Tenant ID
pos_system: POS system name
payload: Inventory data from POS system
Returns:
bool: True if handled successfully
"""
try:
# Extract inventory data
inventory_data = self._parse_inventory_data(pos_system, payload)
if not inventory_data:
logger.warning("Failed to parse inventory data", pos_system=pos_system)
return False
# Update inventory via inventory service client
from shared.clients.inventory_client import InventoryServiceClient
from shared.config.base import get_settings
config = get_settings()
inventory_client = InventoryServiceClient(config, "pos")
for item in inventory_data.get('items', []):
product_id = item.get('product_id')
new_quantity = item.get('quantity', 0)
if not product_id:
continue
# Sync inventory level
try:
await inventory_client.sync_stock_level(
tenant_id=tenant_id,
product_id=product_id,
quantity=new_quantity,
source=f"POS sync - {pos_system}"
)
logger.info(
"Inventory synced from POS",
product_id=product_id,
new_quantity=new_quantity,
pos_system=pos_system
)
except Exception as inv_error:
logger.error(
"Failed to sync inventory",
product_id=product_id,
error=str(inv_error)
)
return True
except Exception as e:
logger.error(
"Error handling inventory updated",
error=str(e),
pos_system=pos_system,
exc_info=True
)
return False
def _parse_sale_data(self, pos_system: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Parse sale data from various POS system formats
Args:
pos_system: POS system name
payload: Raw payload from POS webhook
Returns:
Normalized transaction data
"""
try:
if pos_system.lower() == 'square':
return self._parse_square_sale(payload)
elif pos_system.lower() == 'shopify':
return self._parse_shopify_sale(payload)
elif pos_system.lower() == 'toast':
return self._parse_toast_sale(payload)
else:
# Generic parser for custom POS systems
return self._parse_generic_sale(payload)
except Exception as e:
logger.error("Error parsing sale data", pos_system=pos_system, error=str(e))
return None
def _parse_square_sale(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Parse Square POS sale format"""
payment = payload.get('payment', {})
order = payment.get('order', {})
line_items = order.get('line_items', [])
items = []
for item in line_items:
items.append({
'product_id': item.get('catalog_object_id'),
'product_name': item.get('name'),
'quantity': float(item.get('quantity', 1)),
'unit_price': float(item.get('base_price_money', {}).get('amount', 0)) / 100,
'unit_of_measure': 'units'
})
return {
'transaction_id': payment.get('id'),
'total_amount': float(payment.get('amount_money', {}).get('amount', 0)) / 100,
'items': items,
'payment_method': payment.get('card_details', {}).get('card', {}).get('card_brand', 'unknown'),
'transaction_date': payment.get('created_at'),
'customer_id': payment.get('customer_id')
}
def _parse_shopify_sale(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Parse Shopify POS sale format"""
line_items = payload.get('line_items', [])
items = []
for item in line_items:
items.append({
'product_id': str(item.get('product_id')),
'product_name': item.get('title'),
'quantity': float(item.get('quantity', 1)),
'unit_price': float(item.get('price', 0)),
'unit_of_measure': 'units'
})
return {
'transaction_id': str(payload.get('id')),
'total_amount': float(payload.get('total_price', 0)),
'items': items,
'payment_method': payload.get('payment_gateway_names', ['unknown'])[0],
'transaction_date': payload.get('created_at'),
'customer_id': str(payload.get('customer', {}).get('id')) if payload.get('customer') else None
}
def _parse_toast_sale(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Parse Toast POS sale format"""
selections = payload.get('selections', [])
items = []
for item in selections:
items.append({
'product_id': item.get('guid'),
'product_name': item.get('displayName'),
'quantity': float(item.get('quantity', 1)),
'unit_price': float(item.get('preDiscountPrice', 0)),
'unit_of_measure': 'units'
})
return {
'transaction_id': payload.get('guid'),
'total_amount': float(payload.get('totalAmount', 0)),
'items': items,
'payment_method': payload.get('payments', [{}])[0].get('type', 'unknown'),
'transaction_date': payload.get('closedDate'),
'customer_id': payload.get('customer', {}).get('guid')
}
def _parse_generic_sale(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Parse generic/custom POS sale format"""
items = []
for item in payload.get('items', []):
items.append({
'product_id': item.get('product_id') or item.get('id'),
'product_name': item.get('name') or item.get('description'),
'quantity': float(item.get('quantity', 1)),
'unit_price': float(item.get('price', 0)),
'unit_of_measure': item.get('unit_of_measure', 'units')
})
return {
'transaction_id': payload.get('transaction_id') or payload.get('id'),
'total_amount': float(payload.get('total', 0)),
'items': items,
'payment_method': payload.get('payment_method', 'unknown'),
'transaction_date': payload.get('timestamp') or payload.get('created_at'),
'customer_id': payload.get('customer_id')
}
def _parse_refund_data(self, pos_system: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Parse refund data from various POS systems"""
# Similar parsing logic as sales, but for refunds
# Simplified for now - would follow same pattern as _parse_sale_data
return {
'refund_id': payload.get('refund_id') or payload.get('id'),
'original_transaction_id': payload.get('original_transaction_id'),
'refund_amount': float(payload.get('amount', 0)),
'items': payload.get('items', []),
'refund_date': payload.get('refund_date') or payload.get('created_at')
}
def _parse_inventory_data(self, pos_system: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Parse inventory data from various POS systems"""
return {
'items': payload.get('items', [])
}
# Factory function for creating consumer instance
def create_pos_event_consumer(db_session: AsyncSession) -> POSEventConsumer:
"""Create POS event consumer instance"""
return POSEventConsumer(db_session)