Files
service-finder/backend/app/services/commission_service.py
2026-07-25 10:22:03 +00:00

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),
)