""" 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)