# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Feature Store routes. Own Instance Pattern: Each activation creates a new FeatureInstance in the user's explicit mandate. Supports Orphan Control. """ from fastapi import APIRouter, HTTPException, Depends, Request from typing import List, Dict, Any, Optional, Union from fastapi import status import logging from pydantic import BaseModel, Field from modules.auth import limiter, getRequestContext, RequestContext from modules.datamodels.datamodelFeatures import FeatureInstance from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole from modules.datamodels.datamodelRbac import AccessRuleContext, Role from modules.datamodels.datamodelUam import Mandate from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.security.rbacCatalog import getCatalogService from modules.security.rbac import RbacClass from modules.security.rootAccess import getRootDbAppConnector from modules.shared.i18nRegistry import apiRouteContext, resolveText routeApiMsg = apiRouteContext("routeStore") logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/store", tags=["Store"], responses={404: {"description": "Not found"}} ) class StoreActivateRequest(BaseModel): """Request model for activating a store feature.""" featureCode: str = Field(..., description="Feature code to activate") mandateId: str = Field(..., description="Target mandate ID — always explicit, never optional") class StoreDeactivateRequest(BaseModel): """Request model for deactivating a store feature.""" featureCode: str = Field(..., description="Feature code to deactivate") mandateId: str = Field(..., description="Mandate ID") instanceId: str = Field(..., description="FeatureInstance ID to deactivate") class StoreFeatureResponse(BaseModel): """Response model for a store feature.""" featureCode: str label: str icon: str description: str = "" instances: List[Dict[str, Any]] = [] canActivate: bool def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]: """Get all features available in the store. Soft-disabled features (``enabled=False`` in their feature definition) are skipped so that legacy or temporarily-deactivated modules do not appear in the storefront, even if their ``resource.store.*`` catalog object is still registered. """ resourceObjects = catalogService.getResourceObjects() storeFeatures = [] for obj in resourceObjects: meta = obj.get("meta", {}) if meta.get("category") == "store": featureCode = meta.get("featureCode") if featureCode: featureDef = catalogService.getFeatureDefinition(featureCode) if featureDef and featureDef.get("enabled", True): storeFeatures.append(featureDef) return storeFeatures def _isUserAdminInMandate(db, userId: str, mandateId: str) -> bool: """Check if user has admin role in a mandate.""" userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mandateId, "enabled": True}) if not userMandates: return False umId = userMandates[0].get("id") umRoles = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId}) for umRole in umRoles: roleId = umRole.get("roleId") roles = db.getRecordset(Role, recordFilter={"id": roleId}) for role in roles: if "admin" in (role.get("roleLabel") or "").lower(): return True return False def _autoActivatePending(subInterface, pendingSub: Dict[str, Any]) -> None: """Auto-activate a PENDING subscription to its target operative status.""" from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, BUILTIN_PLANS from datetime import datetime, timezone, timedelta subId = pendingSub.get("id") planKey = pendingSub.get("planKey", "") plan = BUILTIN_PLANS.get(planKey) now = datetime.now(timezone.utc) targetStatus = SubscriptionStatusEnum.TRIALING if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE additionalData = {"currentPeriodStart": now.isoformat()} if plan and plan.trialDays: trialEnd = now + timedelta(days=plan.trialDays) additionalData["trialEndsAt"] = trialEnd.isoformat() additionalData["currentPeriodEnd"] = trialEnd.isoformat() try: subInterface.transitionStatus( subId, expectedFromStatus=SubscriptionStatusEnum.PENDING, toStatus=targetStatus, additionalData=additionalData, ) logger.info("Auto-activated PENDING subscription %s -> %s for mandate", subId, targetStatus.value) except Exception as e: logger.warning("Failed to auto-activate PENDING subscription %s: %s", subId, e) def _getUserAdminMandateIds(db, userId: str) -> List[str]: """Get all mandate IDs where user is admin.""" userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True}) adminMandateIds = [] for um in userMandates: mandateId = um.get("mandateId") mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId}) if mandate and mandate[0].get("isSystem"): continue if _isUserAdminInMandate(db, userId, mandateId): adminMandateIds.append(mandateId) return adminMandateIds def _getUserInstancesForFeature(db, userId: str, featureCode: str, mandateIds: List[str]) -> List[Dict[str, Any]]: """Get user's active instances for a feature across their mandates.""" instances = [] for mandateId in mandateIds: mandateInstances = db.getRecordset( FeatureInstance, recordFilter={"mandateId": mandateId, "featureCode": featureCode} ) for inst in mandateInstances: instanceId = inst.get("id") accesses = db.getRecordset( FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId} ) if accesses: mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId}) mandateName = mandate[0].get("label") or mandate[0].get("name") if mandate else mandateId instances.append({ "instanceId": instanceId, "mandateId": mandateId, "mandateName": mandateName, "label": inst.get("label", ""), "isActive": True, }) return instances @router.get("/mandates", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") def listUserMandates( request: Request, context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: """ List mandates where the user can activate features (admin mandates). Returns empty list if user has no admin mandates — the frontend handles this via OnboardingAssistant/OnboardingWizard to create a mandate. """ try: rootInterface = getRootInterface() db = rootInterface.db userId = str(context.user.id) adminMandateIds = _getUserAdminMandateIds(db, userId) result = [] for mid in adminMandateIds: records = db.getRecordset(Mandate, recordFilter={"id": mid}) if records: m = records[0] if not m.get("enabled", True): continue result.append({ "id": mid, "name": m.get("name", ""), "label": m.get("label") or m.get("name", ""), }) return result except Exception as e: logger.error(f"Error listing user mandates: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/subscription-info", response_model=Dict[str, Any]) @limiter.limit("60/minute") def getSubscriptionInfo( request: Request, mandateId: str = None, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Get subscription info for a mandate (plan, limits).""" try: rootInterface = getRootInterface() db = rootInterface.db userId = str(context.user.id) if not mandateId: adminMandateIds = _getUserAdminMandateIds(db, userId) if adminMandateIds: mandateId = adminMandateIds[0] if not mandateId: return { "plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None, "budgetAiCHF": None, } from modules.datamodels.datamodelSubscription import BUILTIN_PLANS from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot subInterface = _getSubRoot() allSubs = subInterface.listForMandate(mandateId) if not allSubs: return { "plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None, "budgetAiCHF": None, } operative = subInterface.getOperativeForMandate(mandateId) sub = operative or allSubs[0] plan = BUILTIN_PLANS.get(sub.get("planKey")) currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) return { "plan": sub.get("planKey"), "status": sub.get("status"), "operative": operative is not None, "maxDataVolumeMB": plan.maxDataVolumeMB if plan else None, "maxFeatureInstances": plan.maxFeatureInstances if plan else None, "includedModules": plan.includedModules if plan else 0, "budgetAiCHF": plan.budgetAiCHF if plan else None, "budgetAiPerUserCHF": plan.budgetAiPerUserCHF if plan else None, "currentFeatureInstances": len(currentInstances), "trialEndsAt": sub.get("trialEndsAt"), } except Exception as e: logger.error(f"Error getting subscription info: {e}") return { "plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None, "budgetAiCHF": None, } @router.get("/features", response_model=List[StoreFeatureResponse]) @limiter.limit("60/minute") def listStoreFeatures( request: Request, context: RequestContext = Depends(getRequestContext) ) -> List[StoreFeatureResponse]: """List all store features with activation status per mandate.""" try: rootInterface = getRootInterface() db = rootInterface.db catalogService = getCatalogService() userId = str(context.user.id) userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True}) userMandateIds = [] for um in userMandates: mid = um.get("mandateId") mRecord = db.getRecordset(Mandate, recordFilter={"id": mid}) if mRecord and not mRecord[0].get("isSystem") and mRecord[0].get("enabled", True): userMandateIds.append(mid) storeFeatures = _getStoreFeatures(catalogService) result = [] for featureDef in storeFeatures: featureCode = featureDef["code"] instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds) result.append(StoreFeatureResponse( featureCode=featureCode, label=resolveText(featureDef.get("label")), icon=featureDef.get("icon", "mdi-puzzle"), description=resolveText(featureDef.get("description")), instances=instances, canActivate=True, )) return result except HTTPException: raise except Exception as e: logger.error(f"Error listing store features: {e}") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) @router.post("/activate", response_model=Dict[str, Any]) @limiter.limit("10/minute") def activateStoreFeature( request: Request, data: StoreActivateRequest, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Activate a store feature. Billing-gated: a feature instance is ONLY created if the Stripe subscription quantity update succeeds (proration confirmed). On any billing failure the provisioned instance is rolled back. """ featureCode = data.featureCode userId = str(context.user.id) try: rootInterface = getRootInterface() db = rootInterface.db catalogService = getCatalogService() featureDef = catalogService.getFeatureDefinition(featureCode) if not featureDef: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature '{featureCode}' not found") mandateId = data.mandateId if not _isUserAdminInMandate(db, userId, mandateId): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Not admin in target mandate")) # ── 1. Resolve subscription & plan ────────────────────────────── from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS, SubscriptionStatusEnum from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot subInterface = _getSubRoot() operative = subInterface.getOperativeForMandate(mandateId) if not operative: allSubs = subInterface.listForMandate(mandateId) pendingSubs = [s for s in allSubs if s.get("status") == SubscriptionStatusEnum.PENDING.value] if pendingSubs: _autoActivatePending(subInterface, pendingSubs[0]) operative = subInterface.getOperativeForMandate(mandateId) if not operative: allSubs = subInterface.listForMandate(mandateId) statuses = [s.get("status") for s in allSubs] if allSubs else [] logger.warning( "Store activate 402: no operative subscription for mandate %s. " "Found %d subscription(s) with statuses: %s", mandateId, len(allSubs), statuses, ) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=routeApiMsg("Kein aktives Abonnement. Bitte zuerst ein Abo abschliessen."), ) planKey = operative.get("planKey", "") plan = BUILTIN_PLANS.get(planKey) hasStripeIds = bool(operative.get("stripeSubscriptionId") and operative.get("stripeItemIdInstances")) currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) willExceedIncluded = len(currentInstances) >= (plan.includedModules if plan else 0) isBillable = hasStripeIds and plan is not None and (plan.pricePerFeatureInstanceCHF or 0) > 0 and willExceedIncluded # ── 2. Capacity check ─────────────────────────────────────────── if plan and plan.maxFeatureInstances is not None: currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) if len(currentInstances) >= plan.maxFeatureInstances: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=f"Modul-Limit erreicht ({plan.maxFeatureInstances}). Bitte Plan upgraden.", ) # ── 3. Provision instance ─────────────────────────────────────── featureInterface = getFeatureInterface(db) featureLabel = resolveText(featureDef.get("label")) instance = featureInterface.createFeatureInstance( featureCode=featureCode, mandateId=mandateId, label=featureLabel, enabled=True, copyTemplateRoles=True, ) if not instance: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Failed to create feature instance")) instanceId = instance.get("id") if isinstance(instance, dict) else instance.id instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId}) adminRoleId = None for ir in instanceRoles: roleLabel = (ir.get("roleLabel") or "").lower() if roleLabel.endswith("-admin"): adminRoleId = ir.get("id") break if not adminRoleId: _rollbackInstance(db, instanceId) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Keine Admin-Rolle für Modul {featureCode} gefunden — Rollback.", ) rootInterface.createFeatureAccess(userId, instanceId, roleIds=[adminRoleId]) # ── 4. Billing gate: Stripe quantity sync (MUST succeed) ──────── if isBillable: try: rootInterface._syncSubscriptionQuantity(mandateId, raiseOnError=True) except Exception as e: logger.error("Stripe billing for feature activation failed — rolling back instance %s: %s", instanceId, e) _rollbackInstance(db, instanceId, userId=userId) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=f"Stripe-Abrechnung fehlgeschlagen: {e}. Feature wurde NICHT aktiviert.", ) else: try: rootInterface._syncSubscriptionQuantity(mandateId) except Exception as e: logger.warning("Non-critical Stripe sync failed for free feature: %s", e) # ── 5. Confirmed — notify ────────────────────────────────────── _notifyFeatureActivation(mandateId, featureLabel, featureCode, sub=operative, plan=plan) logger.info("User %s activated '%s' in mandate %s (instance=%s, billed=%s)", userId, featureCode, mandateId, instanceId, isBillable) return { "featureCode": featureCode, "mandateId": mandateId, "instanceId": instanceId, "activated": True, } except HTTPException: raise except Exception as e: logger.error(f"Error activating store feature '{featureCode}': {e}") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) @router.post("/deactivate", response_model=Dict[str, Any]) @limiter.limit("10/minute") def deactivateStoreFeature( request: Request, data: StoreDeactivateRequest, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Deactivate a store feature. Removes user's FeatureAccess. Orphan Control: if last user deactivates, FeatureInstance is deleted. """ userId = str(context.user.id) instanceId = data.instanceId mandateId = data.mandateId try: rootInterface = getRootInterface() db = rootInterface.db # Verify instance exists in mandate instances = db.getRecordset(FeatureInstance, recordFilter={"id": instanceId, "mandateId": mandateId}) if not instances: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Feature instance not found in mandate")) # Find user's FeatureAccess accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId}) if not accesses: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("No active access found")) featureAccessId = accesses[0].get("id") db.recordDelete(FeatureAccess, featureAccessId) # Orphan Control: check if any FeatureAccess remains remainingAccesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId}) instanceDeleted = False if not remainingAccesses: db.recordDelete(FeatureInstance, instanceId) instanceDeleted = True logger.info(f"Orphan Control: deleted instance {instanceId} (no remaining accesses)") try: rootInterface._syncSubscriptionQuantity(mandateId, raiseOnError=True) except Exception as e: logger.error("Stripe quantity sync after deactivation failed for mandate %s: %s", mandateId, e) logger.info(f"User {userId} deactivated instance {instanceId} in mandate {mandateId} (deleted={instanceDeleted})") return { "featureCode": data.featureCode, "mandateId": mandateId, "instanceId": instanceId, "deactivated": True, "instanceDeleted": instanceDeleted, } except HTTPException: raise except Exception as e: logger.error(f"Error deactivating store feature: {e}") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) # ============================================================================ # Internal helpers # ============================================================================ def _rollbackInstance(db, instanceId: str, userId: str = None) -> None: """Delete a freshly provisioned FeatureInstance (and its access) on billing failure.""" try: if userId: accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId}) for a in accesses: db.recordDelete(FeatureAccess, a.get("id")) db.recordDelete(FeatureInstance, instanceId) logger.info("Rolled back feature instance %s (billing gate)", instanceId) except Exception as e: logger.error("Rollback of instance %s failed: %s", instanceId, e) def _notifyFeatureActivation( mandateId: str, featureLabel: str, featureCode: str, sub: dict = None, plan = None, ) -> None: """Send email notification to mandate admins about a newly activated feature.""" try: from modules.shared.notifyMandateAdmins import notifyMandateAdmins priceLine = "" if plan and plan.pricePerFeatureInstanceCHF: priceLine = f"Kosten: CHF {plan.pricePerFeatureInstanceCHF:.2f} / {plan.billingPeriod.value} (anteilig via Stripe-Proration)." bodyParagraphs = [ f"Das Modul «{featureLabel}» ({featureCode}) wurde soeben für Ihren Mandanten aktiviert.", ] if priceLine: bodyParagraphs.append(priceLine) bodyParagraphs.append("Die Stripe-Abrechnung wird automatisch angepasst.") notifyMandateAdmins( mandateId=mandateId, subject=f"Modul aktiviert: {featureLabel}", headline="Neues Modul aktiviert", bodyParagraphs=bodyParagraphs, ) except Exception as e: logger.warning("_notifyFeatureActivation failed for mandate %s: %s", mandateId, e)