# /opt/docker/dev/service_finder/backend/app/services/commission_service.py """ CommissionRule service layer — CRUD + Priority Resolution Engine + 2-Level MLM Distribution. THOUGHT PROCESS: - The Priority Algorithm (Campaign > Region > Tier) is implemented in get_active_rule() using SQLAlchemy case() expressions for server-side ordering. This avoids loading all matching rules into Python memory. - Campaign rules (is_campaign=True) always sort before permanent rules. - Specific region match (e.g. "HU") sorts before "GLOBAL". - Higher tiers (PLATINUM=0, VIP=1, STANDARD=2, ENTERPRISE=3) sort first. - Soft-delete is handled via is_active=False (not actual row deletion). - The service uses async/await throughout for FastAPI compatibility. - 2-Level MLM: distribute_commission() resolves Gen1 (direct referrer) and Gen2 (upline) payouts using commission_percent and upline_commission_percent. """ import logging from datetime import date from decimal import Decimal from typing import Optional, List, Sequence from sqlalchemy import select, func, case, or_, and_, delete as sa_delete from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.marketplace.commission import ( CommissionRule, CommissionRuleType, CommissionTier, ) from app.models.identity.identity import User from app.schemas.commission import ( CommissionRuleCreate, CommissionRuleUpdate, CommissionDistributionRequest, CommissionDistributionItem, CommissionDistributionResponse, ) logger = logging.getLogger(__name__) # ────────────────────────────────────────────────────────────────────────────── # CRUD Operations # ────────────────────────────────────────────────────────────────────────────── async def create_commission_rule( db: AsyncSession, data: CommissionRuleCreate, admin_user_id: int, ) -> CommissionRule: """ Create a new commission rule with validation. Args: db: Database session. data: Pydantic schema with rule data. admin_user_id: ID of the admin creating the rule. Returns: The newly created CommissionRule instance. """ rule = CommissionRule( rule_type=CommissionRuleType(data.rule_type.value), tier=CommissionTier(data.tier.value), region_code=data.region_code, xp_reward=data.xp_reward, credit_reward=data.credit_reward, commission_percent=data.commission_percent, upline_commission_percent=data.upline_commission_percent, renewal_commission_percent=data.renewal_commission_percent, commission_max_amount=data.commission_max_amount, is_campaign=data.is_campaign, start_date=data.start_date, end_date=data.end_date, name=data.name, description=data.description, is_active=data.is_active, created_by=admin_user_id, ) db.add(rule) await db.commit() await db.refresh(rule) logger.info( "Commission rule created: id=%d type=%s tier=%s region=%s", rule.id, rule.rule_type, rule.tier, rule.region_code, ) return rule async def update_commission_rule( db: AsyncSession, rule_id: int, data: CommissionRuleUpdate, ) -> Optional[CommissionRule]: """ Partially update an existing commission rule. Only the fields explicitly set in the update schema are applied. Returns None if the rule does not exist. """ stmt = select(CommissionRule).where(CommissionRule.id == rule_id) result = await db.execute(stmt) rule = result.scalar_one_or_none() if not rule: return None update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): # Map enum fields from schema enums to model enums if field == "rule_type" and value is not None: setattr(rule, field, CommissionRuleType(value.value)) elif field == "tier" and value is not None: setattr(rule, field, CommissionTier(value.value)) else: setattr(rule, field, value) await db.commit() await db.refresh(rule) logger.info("Commission rule updated: id=%d", rule.id) return rule async def deactivate_commission_rule( db: AsyncSession, rule_id: int, ) -> Optional[CommissionRule]: """ Soft-delete a commission rule by setting is_active=False. Returns the deactivated rule, or None if not found. """ stmt = select(CommissionRule).where(CommissionRule.id == rule_id) result = await db.execute(stmt) rule = result.scalar_one_or_none() if not rule: return None rule.is_active = False await db.commit() await db.refresh(rule) logger.info("Commission rule deactivated: id=%d", rule.id) return rule async def hard_delete_commission_rule( db: AsyncSession, rule_id: int, ) -> bool: """ Permanently delete a commission rule (admin-only, use with caution). Returns True if deleted, False if not found. """ stmt = select(CommissionRule).where(CommissionRule.id == rule_id) result = await db.execute(stmt) rule = result.scalar_one_or_none() if not rule: return False await db.delete(rule) await db.commit() logger.info("Commission rule hard-deleted: id=%d", rule_id) return True # ────────────────────────────────────────────────────────────────────────────── # Priority Resolution Engine # ────────────────────────────────────────────────────────────────────────────── async def get_active_rule( db: AsyncSession, rule_type: CommissionRuleType, tier: CommissionTier, region_code: str, transaction_date: date, ) -> Optional[CommissionRule]: """ Priority Resolution Algorithm: Find the most specific active rule. Resolution order (highest priority first): 1. Campaign rules (is_campaign=True) over permanent rules 2. Most specific region match (e.g. "HU" > "GLOBAL") 3. Highest tier match (PLATINUM > VIP > STANDARD > ENTERPRISE) Falls back to GLOBAL/STANDARD default if no specific match exists. Args: db: Database session. rule_type: L1_REWARD or L2_COMMISSION. tier: The referrer/buyer's tier. region_code: ISO 3166-1 alpha-2 region code. transaction_date: The date of the transaction. Returns: The best matching CommissionRule, or None if no rule exists. """ # Build priority expressions using SQLAlchemy case() # Lower numeric value = higher priority campaign_priority = case( (CommissionRule.is_campaign == True, 0), # campaigns first else_=1 ) region_priority = case( (CommissionRule.region_code == region_code, 0), # exact region match (CommissionRule.region_code == "GLOBAL", 1), # global fallback else_=2 ) # Tier ordering: PLATINUM (0) > VIP (1) > STANDARD (2) > ENTERPRISE (3) > CONTRACTED (4) tier_order = { CommissionTier.PLATINUM: 0, CommissionTier.VIP: 1, CommissionTier.STANDARD: 2, CommissionTier.ENTERPRISE: 3, CommissionTier.CONTRACTED: 4, } tier_priority = case( *[(CommissionRule.tier == k, v) for k, v in tier_order.items()], else_=99 ) stmt = ( select(CommissionRule) .where( CommissionRule.rule_type == rule_type, CommissionRule.is_active == True, # Date range: NULL means "always valid" or_( CommissionRule.start_date.is_(None), CommissionRule.start_date <= transaction_date ), or_( CommissionRule.end_date.is_(None), CommissionRule.end_date >= transaction_date ), # Region: match exact or GLOBAL or_( CommissionRule.region_code == region_code, CommissionRule.region_code == "GLOBAL" ), # Tier: match exact or STANDARD fallback or_( CommissionRule.tier == tier, CommissionRule.tier == CommissionTier.STANDARD ) ) .order_by(campaign_priority, region_priority, tier_priority) .limit(1) ) result = await db.execute(stmt) rule = result.scalar_one_or_none() if rule: logger.debug( "Active rule resolved: id=%d type=%s tier=%s region=%s campaign=%s", rule.id, rule.rule_type, rule.tier, rule.region_code, rule.is_campaign, ) else: logger.warning( "No active rule found for type=%s tier=%s region=%s date=%s", rule_type, tier, region_code, transaction_date, ) return rule # ────────────────────────────────────────────────────────────────────────────── # Admin Listing with Filters & Pagination # ────────────────────────────────────────────────────────────────────────────── async def list_rules( db: AsyncSession, page: int = 1, page_size: int = 20, rule_type: Optional[CommissionRuleType] = None, tier: Optional[CommissionTier] = None, region_code: Optional[str] = None, is_active: Optional[bool] = None, is_campaign: Optional[bool] = None, ) -> tuple[Sequence[CommissionRule], int]: """ List commission rules with optional filters and pagination. Returns: Tuple of (rules_list, total_count). """ # Build base query base_query = select(CommissionRule) # Apply filters conditions = [] if rule_type is not None: conditions.append(CommissionRule.rule_type == rule_type) if tier is not None: conditions.append(CommissionRule.tier == tier) if region_code is not None: conditions.append(CommissionRule.region_code == region_code) if is_active is not None: conditions.append(CommissionRule.is_active == is_active) if is_campaign is not None: conditions.append(CommissionRule.is_campaign == is_campaign) if conditions: base_query = base_query.where(and_(*conditions)) # Get total count count_query = select(func.count()).select_from(base_query.subquery()) count_result = await db.execute(count_query) total = count_result.scalar() or 0 # Apply pagination and ordering offset = (page - 1) * page_size stmt = ( base_query .order_by(CommissionRule.updated_at.desc()) .offset(offset) .limit(page_size) ) result = await db.execute(stmt) rules = result.scalars().all() return rules, total # ────────────────────────────────────────────────────────────────────────────── # 2-Level MLM Commission Distribution Engine # ────────────────────────────────────────────────────────────────────────────── async def _lookup_user( db: AsyncSession, user_id: int, ) -> Optional[User]: """Look up a user by ID, return None if not found or deleted.""" stmt = select(User).where( User.id == user_id, User.is_deleted == False, ) result = await db.execute(stmt) return result.scalar_one_or_none() async def _calculate_commission( amount: float, percent: Optional[float], max_amount: Optional[float], ) -> float: """ Calculate commission amount from a percentage, capped by max_amount. Args: amount: The transaction/subscription amount. percent: The commission percentage (e.g. 5.00 = 5%). max_amount: Optional cap on the commission payout. Returns: The calculated commission amount. """ if not percent or percent <= 0: return 0.0 commission = float(Decimal(str(amount)) * Decimal(str(percent)) / Decimal("100")) if max_amount is not None and max_amount > 0: commission = min(commission, float(max_amount)) return round(commission, 2) async def distribute_commission( db: AsyncSession, request: CommissionDistributionRequest, ) -> CommissionDistributionResponse: """ 2-Level MLM Commission Distribution Engine. When a referred company makes a purchase, this function: 1. Looks up the buyer and their referrer (Gen1) 2. Finds the active commission rule for Gen1's tier/region 3. Calculates Gen1's commission using commission_percent 4. If Gen1 has a referrer (Gen2/upline), calculates Gen2's commission using upline_commission_percent from the SAME rule Args: db: Database session. request: Distribution request with buyer_user_id, transaction_amount, transaction_date, and region_code. Returns: CommissionDistributionResponse with payout breakdown for Gen1 and Gen2. """ items: List[CommissionDistributionItem] = [] total_commission = 0.0 # 1. Look up the buyer buyer = await _lookup_user(db, request.buyer_user_id) if not buyer: logger.warning( "Commission distribution: buyer user %d not found or deleted", request.buyer_user_id, ) return CommissionDistributionResponse( transaction_amount=request.transaction_amount, items=[], total_commission=0.0, ) # 2. Find Gen1 (the buyer's direct referrer) gen1_user_id = buyer.referred_by_id if not gen1_user_id: logger.info( "Commission distribution: buyer %d has no referrer (Gen1)", request.buyer_user_id, ) return CommissionDistributionResponse( transaction_amount=request.transaction_amount, items=[], total_commission=0.0, ) gen1 = await _lookup_user(db, gen1_user_id) if not gen1: logger.warning( "Commission distribution: Gen1 user %d not found or deleted", gen1_user_id, ) return CommissionDistributionResponse( transaction_amount=request.transaction_amount, items=[], total_commission=0.0, ) # 3. Resolve the active commission rule for Gen1 gen1_tier = CommissionTier(gen1.commission_tier) if hasattr(gen1, 'commission_tier') and gen1.commission_tier else CommissionTier.STANDARD try: gen1_tier_enum = CommissionTier(gen1_tier) except ValueError: gen1_tier_enum = CommissionTier.STANDARD rule = await get_active_rule( db, rule_type=CommissionRuleType.L2_COMMISSION, tier=gen1_tier_enum, region_code=request.region_code, transaction_date=request.transaction_date, ) if not rule: logger.warning( "Commission distribution: no active L2_COMMISSION rule for " "tier=%s region=%s date=%s", gen1_tier_enum, request.region_code, request.transaction_date, ) return CommissionDistributionResponse( transaction_amount=request.transaction_amount, items=[], total_commission=0.0, ) # 4. Calculate Gen1 commission gen1_amount = await _calculate_commission( request.transaction_amount, float(rule.commission_percent) if rule.commission_percent else None, float(rule.commission_max_amount) if rule.commission_max_amount else None, ) if gen1_amount > 0: items.append(CommissionDistributionItem( level=1, user_id=gen1.id, commission_percent=float(rule.commission_percent) if rule.commission_percent else 0.0, commission_amount=gen1_amount, rule_id=rule.id, )) total_commission += gen1_amount # 5. Find Gen2 (Gen1's referrer / upline) gen2_user_id = gen1.referred_by_id if gen2_user_id: gen2 = await _lookup_user(db, gen2_user_id) if gen2: # 6. Calculate Gen2 commission using upline_commission_percent gen2_amount = await _calculate_commission( request.transaction_amount, float(rule.upline_commission_percent) if rule.upline_commission_percent else None, float(rule.commission_max_amount) if rule.commission_max_amount else None, ) if gen2_amount > 0: items.append(CommissionDistributionItem( level=2, user_id=gen2.id, commission_percent=float(rule.upline_commission_percent) if rule.upline_commission_percent else 0.0, commission_amount=gen2_amount, rule_id=rule.id, )) total_commission += gen2_amount else: logger.warning( "Commission distribution: Gen2 user %d not found or deleted", gen2_user_id, ) logger.info( "Commission distributed: buyer=%d gen1=%d(%.2f%%) gen2=%d(%.2f%%) " "amount=%.2f total_commission=%.2f rule=%d", request.buyer_user_id, gen1.id, float(rule.commission_percent) if rule.commission_percent else 0, gen2_user_id or 0, float(rule.upline_commission_percent) if rule.upline_commission_percent else 0, request.transaction_amount, total_commission, rule.id, ) return CommissionDistributionResponse( transaction_amount=request.transaction_amount, items=items, total_commission=round(total_commission, 2), )