529 lines
18 KiB
Python
529 lines
18 KiB
Python
# /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),
|
|
)
|