873 lines
32 KiB
Python
873 lines
32 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.
|
|
- Phase 3: Conflict Detection — before creating/updating a rule, the service
|
|
checks for overlapping active rules with the same tier/region/is_campaign
|
|
combination. If a conflict exists and force_override is False, a 409 is
|
|
returned. If force_override is True, the conflicting rule is deactivated.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import date, datetime, timedelta
|
|
from decimal import Decimal
|
|
from typing import Optional, List, Sequence
|
|
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete, not_
|
|
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, Wallet
|
|
from app.models.system.audit import (
|
|
FinancialLedger,
|
|
LedgerEntryType,
|
|
WalletType,
|
|
LedgerStatus,
|
|
)
|
|
from app.schemas.commission import (
|
|
CommissionRuleCreate,
|
|
CommissionRuleUpdate,
|
|
CommissionDistributionRequest,
|
|
CommissionDistributionItem,
|
|
CommissionDistributionResponse,
|
|
ConflictingRuleInfo,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Conflict Detection
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def check_rule_conflict(
|
|
db: AsyncSession,
|
|
rule_type: CommissionRuleType,
|
|
tier: CommissionTier,
|
|
region_code: str,
|
|
is_campaign: bool,
|
|
exclude_rule_id: Optional[int] = None,
|
|
) -> Optional[CommissionRule]:
|
|
"""
|
|
Check if an active rule already exists with the exact same combination
|
|
of tier, region_code, and is_campaign.
|
|
|
|
Args:
|
|
db: Database session.
|
|
rule_type: L1_REWARD or L2_COMMISSION.
|
|
tier: The tier to check.
|
|
region_code: The region code to check.
|
|
is_campaign: Whether the rule is a campaign rule.
|
|
exclude_rule_id: Optional rule ID to exclude from the check
|
|
(used when updating an existing rule).
|
|
|
|
Returns:
|
|
The conflicting CommissionRule if found, None otherwise.
|
|
"""
|
|
conditions = [
|
|
CommissionRule.rule_type == rule_type,
|
|
CommissionRule.tier == tier,
|
|
CommissionRule.region_code == region_code,
|
|
CommissionRule.is_campaign == is_campaign,
|
|
CommissionRule.is_active == True,
|
|
]
|
|
if exclude_rule_id is not None:
|
|
conditions.append(CommissionRule.id != exclude_rule_id)
|
|
|
|
stmt = select(CommissionRule).where(and_(*conditions)).limit(1)
|
|
result = await db.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# CRUD Operations
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def create_commission_rule(
|
|
db: AsyncSession,
|
|
data: CommissionRuleCreate,
|
|
admin_user_id: int,
|
|
) -> CommissionRule:
|
|
"""
|
|
Create a new commission rule with validation and conflict detection.
|
|
|
|
If an active rule with the same tier/region/is_campaign combination exists
|
|
and force_override is False, the conflicting rule info is returned instead
|
|
(caller should raise 409 Conflict).
|
|
|
|
If force_override is True, the conflicting rule is deactivated (is_active=False)
|
|
and the new rule is saved.
|
|
|
|
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, or the conflicting rule
|
|
if a conflict was detected and force_override was False.
|
|
"""
|
|
# ── Phase 3: Conflict Detection ──────────────────────────────────────
|
|
conflicting = await check_rule_conflict(
|
|
db,
|
|
rule_type=CommissionRuleType(data.rule_type.value),
|
|
tier=CommissionTier(data.tier.value),
|
|
region_code=data.region_code,
|
|
is_campaign=data.is_campaign,
|
|
)
|
|
|
|
if conflicting:
|
|
if not data.force_override:
|
|
logger.warning(
|
|
"Conflict detected: existing active rule id=%d conflicts with "
|
|
"new rule (type=%s tier=%s region=%s campaign=%s). "
|
|
"force_override=False, returning conflict.",
|
|
conflicting.id, data.rule_type, data.tier,
|
|
data.region_code, data.is_campaign,
|
|
)
|
|
return conflicting # Caller must raise 409
|
|
|
|
# Admin override: deactivate the conflicting rule
|
|
logger.info(
|
|
"Admin override: Deactivating overlapping rule ID %d "
|
|
"(type=%s tier=%s region=%s campaign=%s) in favor of new rule.",
|
|
conflicting.id, conflicting.rule_type, conflicting.tier,
|
|
conflicting.region_code, conflicting.is_campaign,
|
|
)
|
|
conflicting.is_active = False
|
|
db.add(conflicting)
|
|
|
|
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,
|
|
# Phase 2: Szintenkénti plafonok és Gen2 megújítás
|
|
gen1_max_amount=data.gen1_max_amount,
|
|
gen2_max_amount=data.gen2_max_amount,
|
|
gen2_renewal_percent=data.gen2_renewal_percent,
|
|
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 with conflict detection.
|
|
|
|
Only the fields explicitly set in the update schema are applied.
|
|
If the update would create a conflict with another active rule and
|
|
force_override is False, the conflicting rule info is returned instead
|
|
(caller should raise 409 Conflict).
|
|
|
|
If force_override is True, the conflicting rule is deactivated.
|
|
|
|
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)
|
|
|
|
# ── Phase 3: Conflict Detection on update ────────────────────────────
|
|
# Determine the effective values after the update
|
|
effective_rule_type = (
|
|
CommissionRuleType(data.rule_type.value)
|
|
if data.rule_type is not None
|
|
else rule.rule_type
|
|
)
|
|
effective_tier = (
|
|
CommissionTier(data.tier.value)
|
|
if data.tier is not None
|
|
else rule.tier
|
|
)
|
|
effective_region = (
|
|
data.region_code
|
|
if data.region_code is not None
|
|
else rule.region_code
|
|
)
|
|
effective_campaign = (
|
|
data.is_campaign
|
|
if data.is_campaign is not None
|
|
else rule.is_campaign
|
|
)
|
|
|
|
conflicting = await check_rule_conflict(
|
|
db,
|
|
rule_type=effective_rule_type,
|
|
tier=effective_tier,
|
|
region_code=effective_region,
|
|
is_campaign=effective_campaign,
|
|
exclude_rule_id=rule_id,
|
|
)
|
|
|
|
if conflicting:
|
|
if not data.force_override:
|
|
logger.warning(
|
|
"Conflict detected on update: existing active rule id=%d conflicts "
|
|
"with update to rule id=%d (type=%s tier=%s region=%s campaign=%s). "
|
|
"force_override=False, returning conflict.",
|
|
conflicting.id, rule_id, effective_rule_type,
|
|
effective_tier, effective_region, effective_campaign,
|
|
)
|
|
return conflicting # Caller must raise 409
|
|
|
|
# Admin override: deactivate the conflicting rule
|
|
logger.info(
|
|
"Admin override on update: Deactivating overlapping rule ID %d "
|
|
"(type=%s tier=%s region=%s campaign=%s) in favor of rule ID %d.",
|
|
conflicting.id, conflicting.rule_type, conflicting.tier,
|
|
conflicting.region_code, conflicting.is_campaign, rule_id,
|
|
)
|
|
conflicting.is_active = False
|
|
db.add(conflicting)
|
|
|
|
for field, value in update_data.items():
|
|
# Skip force_override — it's not a model field
|
|
if field == "force_override":
|
|
continue
|
|
# 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)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Phase 2: Wallet Integration Helpers
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def _credit_commission_to_wallet(
|
|
db: AsyncSession,
|
|
user_id: int,
|
|
amount: float,
|
|
transaction_type: str,
|
|
details: Optional[dict] = None,
|
|
) -> None:
|
|
"""
|
|
Credit commission amount to the user's Wallet.earned_credits and record
|
|
a FinancialLedger CREDIT entry.
|
|
|
|
Follows the proven pattern from GamificationService._add_earned_credits().
|
|
|
|
Args:
|
|
db: Database session.
|
|
user_id: The user receiving the commission.
|
|
amount: The commission amount to credit.
|
|
transaction_type: Label for the ledger entry (e.g. 'COMMISSION_GEN1').
|
|
details: Optional JSON metadata for the ledger entry.
|
|
"""
|
|
stmt = select(Wallet).where(Wallet.user_id == user_id)
|
|
wallet = (await db.execute(stmt)).scalar_one_or_none()
|
|
|
|
if not wallet:
|
|
logger.error(
|
|
"Cannot credit commission: Wallet not found for user_id=%d",
|
|
user_id,
|
|
)
|
|
return
|
|
|
|
decimal_amount = Decimal(str(amount))
|
|
wallet.earned_credits += decimal_amount
|
|
|
|
ledger_entry = FinancialLedger(
|
|
user_id=user_id,
|
|
amount=amount,
|
|
entry_type=LedgerEntryType.CREDIT,
|
|
wallet_type=WalletType.EARNED,
|
|
transaction_type=transaction_type,
|
|
status=LedgerStatus.COMPLETED,
|
|
details=details or {},
|
|
)
|
|
db.add(ledger_entry)
|
|
|
|
logger.info(
|
|
"Commission credited: user_id=%d amount=%.2f type=%s wallet_balance=%s",
|
|
user_id, amount, transaction_type, wallet.earned_credits,
|
|
)
|
|
|
|
|
|
async def _is_user_recently_active(
|
|
db: AsyncSession,
|
|
user_id: int,
|
|
inactivity_days: int = 180,
|
|
) -> bool:
|
|
"""
|
|
Check if a user has been active within the last `inactivity_days` days.
|
|
|
|
A user is considered active if:
|
|
1. Their subscription is active (subscription_expires_at > now), OR
|
|
2. They have a FinancialLedger entry within the inactivity window.
|
|
|
|
Args:
|
|
db: Database session.
|
|
user_id: The user to check.
|
|
inactivity_days: Number of days of inactivity before considered inactive.
|
|
|
|
Returns:
|
|
True if the user is recently active, False otherwise.
|
|
"""
|
|
# Check subscription status first
|
|
user = await _lookup_user(db, user_id)
|
|
if not user:
|
|
return False
|
|
|
|
# If user has an active subscription, they are active
|
|
if user.subscription_expires_at and user.subscription_expires_at > datetime.utcnow():
|
|
return True
|
|
|
|
# Check if there's any FinancialLedger activity within the inactivity window
|
|
cutoff_date = datetime.utcnow() - timedelta(days=inactivity_days)
|
|
stmt = select(FinancialLedger).where(
|
|
FinancialLedger.user_id == user_id,
|
|
FinancialLedger.created_at >= cutoff_date,
|
|
).limit(1)
|
|
result = await db.execute(stmt)
|
|
recent_activity = result.scalar_one_or_none()
|
|
|
|
return recent_activity is not None
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Phase 2: Enhanced 2-Level MLM Commission Distribution Engine
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def distribute_commission(
|
|
db: AsyncSession,
|
|
request: CommissionDistributionRequest,
|
|
) -> CommissionDistributionResponse:
|
|
"""
|
|
2-Level MLM Commission Distribution Engine (Phase 2 Enhanced).
|
|
|
|
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
|
|
- Uses gen1_max_amount as cap (fallback: commission_max_amount)
|
|
4. If Gen1 has a referrer (Gen2/upline), checks Gen1's activity status:
|
|
- If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
|
|
- If Gen1 is active, calculates Gen2's commission
|
|
5. For renewals (is_renewal=True):
|
|
- Gen1 uses commission_percent (same as first-time)
|
|
- Gen2 uses gen2_renewal_percent (fallback: upline_commission_percent)
|
|
- Caps use gen2_max_amount (fallback: commission_max_amount)
|
|
6. Credits commissions to Wallet.earned_credits + FinancialLedger
|
|
|
|
Args:
|
|
db: Database session.
|
|
request: Distribution request with buyer_user_id, transaction_amount,
|
|
transaction_date, region_code, and is_renewal.
|
|
|
|
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,
|
|
)
|
|
|
|
# ── Phase 2: Determine per-level caps ────────────────────────────────
|
|
# gen1_max_amount / gen2_max_amount override commission_max_amount
|
|
# NULL or 0 means unlimited (no cap)
|
|
gen1_cap = (
|
|
float(rule.gen1_max_amount)
|
|
if rule.gen1_max_amount is not None and rule.gen1_max_amount > 0
|
|
else (
|
|
float(rule.commission_max_amount)
|
|
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
|
|
else None
|
|
)
|
|
)
|
|
gen2_cap = (
|
|
float(rule.gen2_max_amount)
|
|
if rule.gen2_max_amount is not None and rule.gen2_max_amount > 0
|
|
else (
|
|
float(rule.commission_max_amount)
|
|
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
|
|
else None
|
|
)
|
|
)
|
|
|
|
# ── Phase 2: Determine Gen2 percent (renewal vs first-time) ──────────
|
|
gen2_percent = (
|
|
float(rule.gen2_renewal_percent)
|
|
if request.is_renewal and rule.gen2_renewal_percent is not None
|
|
else (
|
|
float(rule.upline_commission_percent)
|
|
if rule.upline_commission_percent is not None
|
|
else None
|
|
)
|
|
)
|
|
|
|
# 4. Calculate Gen1 commission
|
|
gen1_amount = await _calculate_commission(
|
|
request.transaction_amount,
|
|
float(rule.commission_percent) if rule.commission_percent else None,
|
|
gen1_cap,
|
|
)
|
|
|
|
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
|
|
|
|
# ── Phase 2: Credit Gen1 commission to Wallet ───────────────────
|
|
await _credit_commission_to_wallet(
|
|
db,
|
|
user_id=gen1.id,
|
|
amount=gen1_amount,
|
|
transaction_type="COMMISSION_GEN1",
|
|
details={
|
|
"rule_id": rule.id,
|
|
"buyer_user_id": request.buyer_user_id,
|
|
"transaction_amount": request.transaction_amount,
|
|
"is_renewal": request.is_renewal,
|
|
},
|
|
)
|
|
|
|
# 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:
|
|
# ── Phase 2: Inactive Gen1 prevention ───────────────────────
|
|
# If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
|
|
gen1_active = await _is_user_recently_active(db, gen1.id)
|
|
if not gen1_active:
|
|
logger.info(
|
|
"Commission distribution: Gen1 user %d is inactive "
|
|
"(>180 days). Skipping Gen2 payout.",
|
|
gen1.id,
|
|
)
|
|
else:
|
|
# 6. Calculate Gen2 commission
|
|
gen2_amount = await _calculate_commission(
|
|
request.transaction_amount,
|
|
gen2_percent,
|
|
gen2_cap,
|
|
)
|
|
|
|
if gen2_amount > 0:
|
|
items.append(CommissionDistributionItem(
|
|
level=2,
|
|
user_id=gen2.id,
|
|
commission_percent=gen2_percent or 0.0,
|
|
commission_amount=gen2_amount,
|
|
rule_id=rule.id,
|
|
))
|
|
total_commission += gen2_amount
|
|
|
|
# ── Phase 2: Credit Gen2 commission to Wallet ───────
|
|
await _credit_commission_to_wallet(
|
|
db,
|
|
user_id=gen2.id,
|
|
amount=gen2_amount,
|
|
transaction_type="COMMISSION_GEN2",
|
|
details={
|
|
"rule_id": rule.id,
|
|
"buyer_user_id": request.buyer_user_id,
|
|
"gen1_user_id": gen1.id,
|
|
"transaction_amount": request.transaction_amount,
|
|
"is_renewal": request.is_renewal,
|
|
},
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Commission distribution: Gen2 user %d not found or deleted",
|
|
gen2_user_id,
|
|
)
|
|
|
|
# ── Phase 2: Commit all wallet/ledger changes ────────────────────────
|
|
await db.commit()
|
|
|
|
logger.info(
|
|
"Commission distributed: buyer=%d gen1=%d(%.2f%%) gen2=%d(%.2f%%) "
|
|
"amount=%.2f total_commission=%.2f rule=%d is_renewal=%s",
|
|
request.buyer_user_id,
|
|
gen1.id,
|
|
float(rule.commission_percent) if rule.commission_percent else 0,
|
|
gen2_user_id or 0,
|
|
gen2_percent or 0,
|
|
request.transaction_amount,
|
|
total_commission,
|
|
rule.id,
|
|
request.is_renewal,
|
|
)
|
|
|
|
return CommissionDistributionResponse(
|
|
transaction_amount=request.transaction_amount,
|
|
items=items,
|
|
total_commission=round(total_commission, 2),
|
|
)
|