# 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("Error getting active tenants", error=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("Tenant service health check failed", error=str(e)) return False # ================================================================ # PAYMENT CUSTOMER MANAGEMENT # ================================================================ async def create_payment_customer( self, user_data: Dict[str, Any], payment_method_id: Optional[str] = None ) -> Optional[Dict[str, Any]]: """ Create a payment customer for a user This method creates a payment customer record in the tenant service during user registration or onboarding. It handles the integration with payment providers and returns the payment customer details. 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) payment_method_id: Optional payment method ID to attach to the customer Returns: Dict with payment customer details including: - success: boolean - payment_customer_id: string - payment_method: dict with payment method details - customer: dict with customer details Returns None if creation fails """ try: logger.info("Creating payment customer via tenant service", user_id=user_data.get('user_id'), email=user_data.get('email')) # Prepare data for tenant service tenant_data = { "user_data": user_data, "payment_method_id": payment_method_id } # Call tenant service endpoint result = await self.post("/payment-customers/create", tenant_data) if result and result.get("success"): logger.info("Payment customer created successfully via tenant service", user_id=user_data.get('user_id'), payment_customer_id=result.get('payment_customer_id')) return result else: logger.error("Payment customer 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 payment customer via tenant service", user_id=user_data.get('user_id'), error=str(e)) return None 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("/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 # Factory function for dependency injection def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient: """Create tenant service client instance""" return TenantServiceClient(config)