# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_commission.py """ Admin API endpoints for CommissionRule CRUD, Priority Resolution Engine, 2-Level MLM Commission Distribution, and Conflict Detection. THOUGHT PROCESS: - All endpoints are prefixed with /admin/commission-rules (registered in api.py). - Staff-level access via get_current_staff dependency (SUPERADMIN, ADMIN, etc.). - The GET /active endpoint implements the Priority Resolution Algorithm (Campaign > Region > Tier) from the service layer. - Pagination, filtering by type/tier/region/active/campaign are supported. - Soft-delete via PATCH setting is_active=False; hard DELETE also available. - POST /distribute implements the 2-Level MLM commission distribution: Gen1 (direct referrer) gets commission_percent, Gen2 (upline) gets upline_commission_percent from the same rule. - Phase 3: Conflict Detection — POST (create) and PUT (update) now check for overlapping active rules. If a conflict is found and force_override is False, a 409 Conflict response is returned with the conflicting rule's details. If force_override is True, the conflicting rule is deactivated. """ import logging from datetime import date from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_staff from app.db.session import get_db from app.models.identity.identity import User from app.models.marketplace.commission import ( CommissionRule, CommissionRuleType, CommissionTier, ) from app.schemas.commission import ( CommissionRuleCreate, CommissionRuleUpdate, CommissionRuleResponse, CommissionRuleListResponse, CommissionDistributionRequest, CommissionDistributionResponse, ConflictResponse, ConflictingRuleInfo, ) from app.services import commission_service logger = logging.getLogger("admin-commission") router = APIRouter() # ────────────────────────────────────────────────────────────────────────────── # LIST: GET /admin/commission-rules # ────────────────────────────────────────────────────────────────────────────── @router.get( "", response_model=CommissionRuleListResponse, tags=["Admin Commission Rules"], ) async def list_commission_rules( page: int = Query(1, ge=1, description="Oldalszám"), page_size: int = Query(20, ge=1, le=100, description="Elemek száma oldalanként"), rule_type: Optional[CommissionRuleType] = Query(None, description="Szűrés típus szerint (L1_REWARD / L2_COMMISSION)"), tier: Optional[CommissionTier] = Query(None, description="Szűrés szint szerint (STANDARD / VIP / PLATINUM / ENTERPRISE)"), region_code: Optional[str] = Query(None, max_length=10, description="Szűrés régió szerint (pl. HU, GLOBAL)"), is_active: Optional[bool] = Query(None, description="Szűrés aktív/inaktív státusz szerint"), is_campaign: Optional[bool] = Query(None, description="Szűrés kampány/állandó szabály szerint"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_staff), ): """List all commission rules with optional filters and pagination.""" rules, total = await commission_service.list_rules( db, page=page, page_size=page_size, rule_type=rule_type, tier=tier, region_code=region_code, is_active=is_active, is_campaign=is_campaign, ) return CommissionRuleListResponse( items=[CommissionRuleResponse.model_validate(r) for r in rules], total=total, page=page, page_size=page_size, ) # ────────────────────────────────────────────────────────────────────────────── # GET ACTIVE RULE: GET /admin/commission-rules/active # ────────────────────────────────────────────────────────────────────────────── @router.get( "/active", response_model=CommissionRuleResponse, tags=["Admin Commission Rules"], ) async def get_active_commission_rule( rule_type: CommissionRuleType = Query(..., description="Jutalék típusa (L1_REWARD / L2_COMMISSION)"), tier: CommissionTier = Query(CommissionTier.STANDARD, description="Felhasználó szintje"), region_code: str = Query("GLOBAL", max_length=10, description="Régiókód (ISO 3166-1 alpha-2)"), transaction_date: date = Query(..., description="Tranzakció dátuma (ISO 8601, pl. 2026-07-24)"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_staff), ): """ Priority Resolution Engine: Find the most specific active rule. Resolution order: 1. Campaign rules over permanent rules 2. Most specific region match (HU > GLOBAL) 3. Highest tier match (PLATINUM > VIP > STANDARD > ENTERPRISE) """ rule = await commission_service.get_active_rule( db, rule_type=rule_type, tier=tier, region_code=region_code, transaction_date=transaction_date, ) if not rule: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No active commission rule found for the given parameters.", ) return CommissionRuleResponse.model_validate(rule) # ────────────────────────────────────────────────────────────────────────────── # GET SINGLE: GET /admin/commission-rules/{rule_id} # ────────────────────────────────────────────────────────────────────────────── @router.get( "/{rule_id}", response_model=CommissionRuleResponse, tags=["Admin Commission Rules"], ) async def get_commission_rule( rule_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_staff), ): """Get a single commission rule by ID.""" from sqlalchemy import select stmt = select(CommissionRule).where(CommissionRule.id == rule_id) result = await db.execute(stmt) rule = result.scalar_one_or_none() if not rule: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Commission rule with id={rule_id} not found.", ) return CommissionRuleResponse.model_validate(rule) # ────────────────────────────────────────────────────────────────────────────── # CREATE: POST /admin/commission-rules # ────────────────────────────────────────────────────────────────────────────── @router.post( "", response_model=CommissionRuleResponse, status_code=status.HTTP_201_CREATED, tags=["Admin Commission Rules"], responses={ 409: { "description": "Conflict — an active rule with the same tier/region/campaign already exists", "model": ConflictResponse, } }, ) async def create_commission_rule( data: CommissionRuleCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_staff), ): """ Create a new commission rule with conflict detection. If an active rule with the same tier/region/is_campaign combination exists and force_override is False, a 409 Conflict is returned with the details of the conflicting rule. If force_override is True, the conflicting rule is deactivated and the new rule is created. """ rule = await commission_service.create_commission_rule( db, data=data, admin_user_id=current_user.id, ) # Phase 3: Detect conflict — the service returns the conflicting rule # when force_override is False and a conflict exists. We detect this by # checking if the returned rule's name differs from the input data. if rule.name != data.name: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={ "message": "A conflicting active rule already exists for this combination.", "conflicting_rule": ConflictingRuleInfo( id=rule.id, name=rule.name, rule_type=CommissionRuleType(rule.rule_type.value), tier=CommissionTier(rule.tier.value), region_code=rule.region_code, is_campaign=rule.is_campaign, is_active=rule.is_active, ).model_dump(), }, ) return CommissionRuleResponse.model_validate(rule) # ────────────────────────────────────────────────────────────────────────────── # UPDATE: PUT /admin/commission-rules/{rule_id} # ────────────────────────────────────────────────────────────────────────────── @router.put( "/{rule_id}", response_model=CommissionRuleResponse, tags=["Admin Commission Rules"], responses={ 409: { "description": "Conflict — an active rule with the same tier/region/campaign already exists", "model": ConflictResponse, } }, ) async def update_commission_rule( rule_id: int, data: CommissionRuleUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_staff), ): """ Update an existing commission rule (partial update) with conflict detection. If the update would create a conflict with another active rule and force_override is False, a 409 Conflict is returned with the details of the conflicting rule. If force_override is True, the conflicting rule is deactivated and the update proceeds. """ rule = await commission_service.update_commission_rule(db, rule_id, data) if not rule: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Commission rule with id={rule_id} not found.", ) # Phase 3: Detect conflict — the service returns the conflicting rule # when force_override is False and a conflict exists. We detect this by # checking if the returned rule's ID differs from the requested rule_id. if rule.id != rule_id: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={ "message": "A conflicting active rule already exists for this combination.", "conflicting_rule": ConflictingRuleInfo( id=rule.id, name=rule.name, rule_type=CommissionRuleType(rule.rule_type.value), tier=CommissionTier(rule.tier.value), region_code=rule.region_code, is_campaign=rule.is_campaign, is_active=rule.is_active, ).model_dump(), }, ) return CommissionRuleResponse.model_validate(rule) # ────────────────────────────────────────────────────────────────────────────── # SOFT DELETE (DEACTIVATE): DELETE /admin/commission-rules/{rule_id} # ────────────────────────────────────────────────────────────────────────────── @router.delete( "/{rule_id}", status_code=status.HTTP_200_OK, tags=["Admin Commission Rules"], ) async def deactivate_commission_rule( rule_id: int, hard_delete: bool = Query(False, description="Végleges törlés (alapértelmezett: soft-delete / deaktiválás)"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_staff), ): """ Deactivate (soft-delete) or permanently delete a commission rule. By default, sets is_active=False (soft delete). Use ?hard_delete=true for permanent removal. """ if hard_delete: deleted = await commission_service.hard_delete_commission_rule(db, rule_id) if not deleted: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Commission rule with id={rule_id} not found.", ) return {"message": f"Commission rule {rule_id} permanently deleted."} rule = await commission_service.deactivate_commission_rule(db, rule_id) if not rule: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Commission rule with id={rule_id} not found.", ) return CommissionRuleResponse.model_validate(rule) # ────────────────────────────────────────────────────────────────────────────── # 2-LEVEL MLM DISTRIBUTION: POST /admin/commission-rules/distribute # ────────────────────────────────────────────────────────────────────────────── @router.post( "/distribute", response_model=CommissionDistributionResponse, tags=["Admin Commission Rules"], ) async def distribute_commission( request: CommissionDistributionRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_staff), ): """ 2-Level MLM Commission Distribution Engine. When a referred company makes a purchase, this endpoint: 1. Looks up the buyer's direct referrer (Gen1) 2. Finds the active L2_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 Returns a breakdown of all commission payouts. """ result = await commission_service.distribute_commission(db, request) return result