# shared/clients/tenant_client.py """ Tenant Service Client for Inter-Service Communication This client provides a high-level API for interacting with the Tenant Service, which manages tenant metadata, settings, hierarchical relationships (parent-child), and multi-location support for enterprise bakery networks. Key Capabilities: - Tenant Management: Get, create, update tenant records - Settings Management: Category-specific settings (procurement, inventory, production, etc.) - Enterprise Hierarchy: Parent-child tenant relationships for multi-location networks - Tenant Locations: Physical location management (central_production, retail_outlet) - Subscription Management: Subscription tier and quota validation - Multi-Tenancy: Tenant isolation and access control Enterprise Hierarchy Features: - get_child_tenants(): Fetch all child outlets for a parent (central bakery) - get_parent_tenant(): Get parent tenant from child outlet - get_tenant_hierarchy(): Get complete hierarchy path and metadata - get_tenant_locations(): Get all physical locations for a tenant - Supports 3 tenant types: standalone, parent, child Usage Example: ```python from shared.clients import create_tenant_client from shared.config.base import get_settings config = get_settings() client = create_tenant_client(config) # Get parent tenant and all children parent = await client.get_tenant(parent_tenant_id) children = await client.get_child_tenants(parent_tenant_id) # Get hierarchy information hierarchy = await client.get_tenant_hierarchy(tenant_id) # Returns: {tenant_type: 'parent', hierarchy_path: 'parent_id', child_count: 3} # Get physical locations locations = await client.get_tenant_locations(parent_tenant_id) # Returns: [{location_type: 'central_production', ...}, ...] # Get category settings procurement_settings = await client.get_procurement_settings(tenant_id) ``` Settings Categories: - procurement: Min/max order quantities, lead times, reorder points - inventory: FIFO settings, expiry thresholds, temperature monitoring - production: Batch sizes, quality control, equipment settings - supplier: Payment terms, delivery preferences - pos: POS integration settings - order: Order fulfillment rules - notification: Alert preferences Service Architecture: - Base URL: Configured via TENANT_SERVICE_URL environment variable - Authentication: Uses BaseServiceClient with tenant_id header validation - Error Handling: Returns None on errors, logs detailed error context - Async: All methods are async and use httpx for HTTP communication Related Services: - Distribution Service: Uses tenant locations for delivery route planning - Forecasting Service: Uses hierarchy for network demand aggregation - Procurement Service: Validates parent-child for internal transfers - Orchestrator Service: Enterprise dashboard queries hierarchy data For more details, see services/tenant/README.md """ import structlog from typing import Dict, Any, Optional, List from uuid import UUID from shared.clients.base_service_client import BaseServiceClient from shared.config.base import BaseServiceSettings logger = structlog.get_logger() class TenantServiceClient(BaseServiceClient): """Client for communicating with the Tenant Service""" def __init__(self, config: BaseServiceSettings): super().__init__("tenant", config) def get_service_base_path(self) -> str: return "/api/v1" # ================================================================ # TENANT SETTINGS ENDPOINTS # ================================================================ async def get_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]: """ Get all settings for a tenant Args: tenant_id: Tenant ID (UUID as string) Returns: Dictionary with all settings categories """ try: result = await self.get("settings", tenant_id=tenant_id) if result: logger.info("Retrieved all settings from tenant service", tenant_id=tenant_id) return result except Exception as e: logger.error("Error getting all settings", error=str(e), tenant_id=tenant_id) return None async def get_category_settings(self, tenant_id: str, category: str) -> Optional[Dict[str, Any]]: """ Get settings for a specific category Args: tenant_id: Tenant ID (UUID as string) category: Category name (procurement, inventory, production, supplier, pos, order) Returns: Dictionary with category settings """ try: result = await self.get(f"settings/{category}", tenant_id=tenant_id) if result: logger.info("Retrieved category settings from tenant service", tenant_id=tenant_id, category=category) return result except Exception as e: logger.error("Error getting category settings", error=str(e), tenant_id=tenant_id, category=category) return None async def get_procurement_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]: """Get procurement settings for a tenant""" result = await self.get_category_settings(tenant_id, "procurement") return result.get('settings', {}) if result else {} async def get_inventory_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]: """Get inventory settings for a tenant""" result = await self.get_category_settings(tenant_id, "inventory") return result.get('settings', {}) if result else {} async def get_production_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]: """Get production settings for a tenant""" result = await self.get_category_settings(tenant_id, "production") return result.get('settings', {}) if result else {} async def get_supplier_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]: """Get supplier settings for a tenant""" result = await self.get_category_settings(tenant_id, "supplier") return result.get('settings', {}) if result else {} async def get_pos_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]: """Get POS settings for a tenant""" result = await self.get_category_settings(tenant_id, "pos") return result.get('settings', {}) if result else {} async def get_order_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]: """Get order settings for a tenant""" result = await self.get_category_settings(tenant_id, "order") return result.get('settings', {}) if result else {} async def get_notification_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]: """Get notification settings for a tenant""" result = await self.get_category_settings(tenant_id, "notification") return result.get('settings', {}) if result else {} async def update_settings(self, tenant_id: str, settings_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """ Update settings for a tenant Args: tenant_id: Tenant ID (UUID as string) settings_data: Settings data to update Returns: Updated settings dictionary """ try: result = await self.put("settings", data=settings_data, tenant_id=tenant_id) if result: logger.info("Updated tenant settings", tenant_id=tenant_id) return result except Exception as e: logger.error("Error updating tenant settings", error=str(e), tenant_id=tenant_id) return None async def update_category_settings( self, tenant_id: str, category: str, settings_data: Dict[str, Any] ) -> Optional[Dict[str, Any]]: """ Update settings for a specific category Args: tenant_id: Tenant ID (UUID as string) category: Category name settings_data: Settings data to update Returns: Updated settings dictionary """ try: result = await self.put(f"settings/{category}", data=settings_data, tenant_id=tenant_id) if result: logger.info("Updated category settings", tenant_id=tenant_id, category=category) return result except Exception as e: logger.error("Error updating category settings", error=str(e), tenant_id=tenant_id, category=category) return None async def reset_category_settings(self, tenant_id: str, category: str) -> Optional[Dict[str, Any]]: """ Reset category settings to default values Args: tenant_id: Tenant ID (UUID as string) category: Category name Returns: Reset settings dictionary """ try: result = await self.post(f"settings/{category}/reset", data={}, tenant_id=tenant_id) if result: logger.info("Reset category settings to defaults", tenant_id=tenant_id, category=category) return result except Exception as e: logger.error("Error resetting category settings", error=str(e), tenant_id=tenant_id, category=category) return None # ================================================================ # TENANT MANAGEMENT # ================================================================ async def get_tenant(self, tenant_id: str) -> Optional[Dict[str, Any]]: """ Get tenant details Args: tenant_id: Tenant ID (UUID as string) Returns: Tenant data dictionary """ try: # The tenant endpoint is not tenant-scoped, it's a direct path result = await self._make_request("GET", f"tenants/{tenant_id}") if result: logger.info("Retrieved tenant details", tenant_id=tenant_id) return result except Exception as e: logger.error("Error getting tenant details", error=str(e), tenant_id=tenant_id) return None async def get_active_tenants(self, skip: int = 0, limit: int = 100) -> Optional[list]: """ Get all active tenants Args: skip: Number of records to skip (pagination) limit: Maximum number of records to return Returns: List of active tenant dictionaries """ try: # Call tenants endpoint (not tenant-scoped) result = await self._make_request( "GET", f"tenants?skip={skip}&limit={limit}" ) if result: logger.info("Retrieved active tenants from tenant service", count=len(result) if isinstance(result, list) else 0) return result if result else [] except Exception as e: logger.error(f"Error getting active tenants: {str(e)}") return [] # ================================================================ # ENTERPRISE TIER METHODS # ================================================================ async def get_child_tenants(self, parent_tenant_id: str) -> Optional[List[Dict[str, Any]]]: """ Get all child tenants for a parent tenant Args: parent_tenant_id: Parent tenant ID Returns: List of child tenant dictionaries """ try: # Use _make_request directly to avoid double tenant_id in URL # The gateway expects: /api/v1/tenants/{tenant_id}/children result = await self._make_request("GET", f"tenants/{parent_tenant_id}/children") if result: logger.info("Retrieved child tenants", parent_tenant_id=parent_tenant_id, child_count=len(result) if isinstance(result, list) else 0) return result except Exception as e: logger.error("Error getting child tenants", error=str(e), parent_tenant_id=parent_tenant_id) return None async def get_tenant_children_count(self, tenant_id: str) -> int: """ Get count of child tenants for a parent tenant Args: tenant_id: Tenant ID to check Returns: Number of child tenants (0 if not a parent) """ try: children = await self.get_child_tenants(tenant_id) return len(children) if children else 0 except Exception as e: logger.error("Error getting child tenant count", error=str(e), tenant_id=tenant_id) return 0 async def get_parent_tenant(self, child_tenant_id: str) -> Optional[Dict[str, Any]]: """ Get parent tenant for a child tenant Args: child_tenant_id: Child tenant ID Returns: Parent tenant dictionary """ try: result = await self.get(f"tenants/{child_tenant_id}/parent", tenant_id=child_tenant_id) if result: logger.info("Retrieved parent tenant", child_tenant_id=child_tenant_id, parent_tenant_id=result.get('id')) return result except Exception as e: logger.error("Error getting parent tenant", error=str(e), child_tenant_id=child_tenant_id) return None async def get_tenant_hierarchy(self, tenant_id: str) -> Optional[Dict[str, Any]]: """ Get complete tenant hierarchy information Args: tenant_id: Tenant ID to get hierarchy for Returns: Hierarchy information dictionary """ try: result = await self.get("hierarchy", tenant_id=tenant_id) if result: logger.info("Retrieved tenant hierarchy", tenant_id=tenant_id, hierarchy_type=result.get('tenant_type')) return result except Exception as e: logger.error("Error getting tenant hierarchy", error=str(e), tenant_id=tenant_id) return None async def get_tenant_locations(self, tenant_id: str) -> Optional[List[Dict[str, Any]]]: """ Get all locations for a tenant Args: tenant_id: Tenant ID Returns: List of tenant location dictionaries """ try: result = await self.get("locations", tenant_id=tenant_id) if result: logger.info("Retrieved tenant locations", tenant_id=tenant_id, location_count=len(result) if isinstance(result, list) else 0) return result except Exception as e: logger.error("Error getting tenant locations", error=str(e), tenant_id=tenant_id) return None # ================================================================ # UTILITY METHODS # ================================================================ async def health_check(self) -> bool: """Check if tenant service is healthy""" try: result = await self.get("../health") # Health endpoint is not tenant-scoped return result is not None except Exception as e: logger.error(f"Tenant service health check failed: {str(e)}") return False async def get_subscription_status(self, tenant_id: str) -> Optional[Dict[str, Any]]: """ Get subscription status for a tenant Args: tenant_id: Tenant ID Returns: Dictionary with subscription status information """ try: result = await self.get(f"tenants/{tenant_id}/subscription/status") if result: logger.info("Retrieved subscription status from tenant service", tenant_id=tenant_id, status=result.get('status')) return result except Exception as e: logger.error("Error getting subscription status", error=str(e), tenant_id=tenant_id) return None async def get_subscription_details(self, tenant_id: str) -> Optional[Dict[str, Any]]: """ Get detailed subscription information for a tenant Args: tenant_id: Tenant ID Returns: Dictionary with subscription details """ try: result = await self.get(f"tenants/{tenant_id}/subscription") if result: logger.info("Retrieved subscription details from tenant service", tenant_id=tenant_id, plan=result.get('plan')) return result except Exception as e: logger.error("Error getting subscription details", error=str(e), tenant_id=tenant_id) return None # ================================================================ # PAYMENT CUSTOMER MANAGEMENT # ================================================================ async def create_subscription_for_registration( self, user_data: Dict[str, Any], plan_id: str, payment_method_id: str, billing_cycle: str = "monthly", coupon_code: Optional[str] = None ) -> Optional[Dict[str, Any]]: """ Create a tenant-independent subscription during user registration This method creates a subscription that is not linked to any tenant yet. The subscription will be linked to a tenant during the onboarding flow when the user creates their bakery/tenant. Args: user_data: User data including: - user_id: User ID (required) - email: User email (required) - full_name: User full name (required) - name: User name (optional, defaults to full_name) plan_id: Subscription plan ID (starter, professional, enterprise) payment_method_id: Stripe payment method ID billing_cycle: Billing cycle (monthly or yearly), defaults to monthly coupon_code: Optional coupon code for discounts/trials Returns: Dict with subscription creation results including: - success: boolean - subscription_id: string (Stripe subscription ID) - customer_id: string (Stripe customer ID) - status: string (subscription status) - plan: string (plan name) - billing_cycle: string (billing interval) - trial_period_days: int (if trial applied) - coupon_applied: boolean Returns None if creation fails """ try: logger.info("Creating tenant-independent subscription for registration", user_id=user_data.get('user_id'), plan_id=plan_id, billing_cycle=billing_cycle) # Prepare data for tenant service subscription_data = { "user_data": user_data, "plan_id": plan_id, "payment_method_id": payment_method_id, "billing_interval": billing_cycle, "coupon_code": coupon_code } # Call tenant service endpoint result = await self.post("tenants/subscriptions/create-for-registration", subscription_data) if result and result.get("success"): data = result.get("data", {}) logger.info("Tenant-independent subscription created successfully", user_id=user_data.get('user_id'), subscription_id=data.get('subscription_id'), plan=data.get('plan')) return data else: logger.error("Subscription creation failed via tenant service", user_id=user_data.get('user_id'), error=result.get('detail') if result else 'No detail provided') return None except Exception as e: logger.error("Failed to create subscription for registration via tenant service", user_id=user_data.get('user_id'), plan_id=plan_id, error=str(e)) return None async def link_subscription_to_tenant( self, tenant_id: str, subscription_id: str, user_id: str ) -> Optional[Dict[str, Any]]: """ Link a pending subscription to a tenant This completes the registration flow by associating the subscription created during registration with the tenant created during onboarding. Args: tenant_id: Tenant ID to link subscription to subscription_id: Subscription ID (from registration) user_id: User ID performing the linking (for validation) Returns: Dict with linking results: - success: boolean - tenant_id: string - subscription_id: string - status: string Returns None if linking fails """ try: logger.info("Linking subscription to tenant", tenant_id=tenant_id, subscription_id=subscription_id, user_id=user_id) # Prepare data for tenant service linking_data = { "subscription_id": subscription_id, "user_id": user_id } # Call tenant service endpoint result = await self.post( f"tenants/{tenant_id}/link-subscription", linking_data ) if result and result.get("success"): logger.info("Subscription linked to tenant successfully", tenant_id=tenant_id, subscription_id=subscription_id) return result else: logger.error("Subscription linking failed via tenant service", tenant_id=tenant_id, subscription_id=subscription_id, error=result.get('detail') if result else 'No detail provided') return None except Exception as e: logger.error("Failed to link subscription to tenant via tenant service", tenant_id=tenant_id, subscription_id=subscription_id, error=str(e)) return None async def create_payment_customer( self, user_data: Dict[str, Any], payment_method_id: Optional[str] = None ) -> Dict[str, Any]: """ Create payment customer (supports pre-user-creation flow) This method creates a payment customer without requiring a user_id, supporting the secure architecture where users are only created after payment verification. Args: user_data: User data (email, full_name, etc.) payment_method_id: Optional payment method ID Returns: Dictionary with payment customer creation result """ try: logger.info("Creating payment customer via tenant service", email=user_data.get('email'), payment_method_id=payment_method_id) # Call tenant service endpoint result = await self.post( "tenants/payment-customers/create", { "user_data": user_data, "payment_method_id": payment_method_id } ) if result and result.get("success"): logger.info("Payment customer created successfully via tenant service", email=user_data.get('email'), payment_customer_id=result.get('payment_customer_id')) return result else: logger.error("Payment customer creation failed via tenant service", email=user_data.get('email'), error=result.get('detail') if result else 'No detail provided') raise Exception("Payment customer creation failed: " + (result.get('detail') if result else 'Unknown error')) except Exception as e: logger.error("Failed to create payment customer via tenant service", email=user_data.get('email'), error=str(e)) raise async def create_registration_payment_setup( self, user_data: Dict[str, Any] ) -> Dict[str, Any]: """ Create registration payment setup via tenant service orchestration This method calls the tenant service's orchestration endpoint to create payment customer and SetupIntent in a coordinated workflow. Args: user_data: User data including email, full_name, payment_method_id, etc. Returns: Dictionary with payment setup results including SetupIntent if required """ try: logger.info("Creating registration payment setup via tenant service orchestration", email=user_data.get('email'), payment_method_id=user_data.get('payment_method_id')) # Call tenant service orchestration endpoint result = await self.post( "registration-payment-setup", user_data ) if result and result.get("success"): logger.info("Registration payment setup completed via tenant service orchestration", email=user_data.get('email'), requires_action=result.get('requires_action')) return result else: logger.error("Registration payment setup failed via tenant service orchestration", email=user_data.get('email'), error=result.get('detail') if result else 'No detail provided') raise Exception("Registration payment setup failed: " + (result.get('detail') if result else 'Unknown error')) except Exception as e: logger.error("Failed to create registration payment setup via tenant service orchestration", email=user_data.get('email'), error=str(e)) raise async def verify_setup_intent_for_registration( self, setup_intent_id: str ) -> Dict[str, Any]: """ Verify SetupIntent status via tenant service orchestration This method calls the tenant service's orchestration endpoint to verify SetupIntent status before proceeding with user creation. Args: setup_intent_id: SetupIntent ID to verify Returns: Dictionary with SetupIntent verification result """ try: logger.info("Verifying SetupIntent via tenant service orchestration", setup_intent_id=setup_intent_id) # Call tenant service orchestration endpoint result = await self.get( f"setup-intents/{setup_intent_id}/verify" ) if result: logger.info("SetupIntent verification result from tenant service orchestration", setup_intent_id=setup_intent_id, status=result.get('status')) return result else: logger.error("SetupIntent verification failed via tenant service orchestration", setup_intent_id=setup_intent_id, error='No result returned') raise Exception("SetupIntent verification failed: No result returned") except Exception as e: logger.error("Failed to verify SetupIntent via tenant service orchestration", setup_intent_id=setup_intent_id, error=str(e)) raise async def verify_setup_intent( self, setup_intent_id: str ) -> Dict[str, Any]: """ Verify SetupIntent status with payment provider Args: setup_intent_id: SetupIntent ID to verify Returns: Dictionary with SetupIntent verification result """ try: logger.info("Verifying SetupIntent via tenant service", setup_intent_id=setup_intent_id) # Call tenant service endpoint result = await self.get( f"tenants/setup-intents/{setup_intent_id}/verify" ) if result: logger.info("SetupIntent verification result from tenant service", setup_intent_id=setup_intent_id, status=result.get('status')) return result else: logger.error("SetupIntent verification failed via tenant service", setup_intent_id=setup_intent_id, error='No result returned') raise Exception("SetupIntent verification failed: No result returned") except Exception as e: logger.error("Failed to verify SetupIntent via tenant service", setup_intent_id=setup_intent_id, error=str(e)) raise async def verify_and_complete_registration( self, setup_intent_id: str, user_data: Dict[str, Any] ) -> Dict[str, Any]: """ Verify SetupIntent and complete registration via tenant service orchestration This method calls the tenant service's orchestration endpoint to verify SetupIntent status and complete the registration process by creating the user record after successful payment verification. Args: setup_intent_id: SetupIntent ID to verify user_data: User data for registration (email, full_name, etc.) Returns: Dictionary with registration completion result including user details and subscription information Raises: Exception: If verification or registration completion fails """ try: logger.info("Verifying SetupIntent and completing registration via tenant service orchestration", setup_intent_id=setup_intent_id, email=user_data.get('email')) # Prepare data for tenant service orchestration registration_data = { "setup_intent_id": setup_intent_id, "user_data": user_data } # Call tenant service orchestration endpoint result = await self.post( "verify-and-complete-registration", registration_data ) if result and result.get("success"): logger.info("Registration completed successfully via tenant service orchestration", setup_intent_id=setup_intent_id, user_id=result.get('user_id'), email=user_data.get('email')) return result else: logger.error("Registration completion failed via tenant service orchestration", setup_intent_id=setup_intent_id, email=user_data.get('email'), error=result.get('detail') if result else 'No detail provided') raise Exception("Registration completion failed: " + (result.get('detail') if result else 'Unknown error')) except Exception as e: logger.error("Failed to complete registration via tenant service orchestration", setup_intent_id=setup_intent_id, email=user_data.get('email'), error=str(e)) raise # Factory function for dependency injection def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient: """Create tenant service client instance""" return TenantServiceClient(config)