Files
remnawave-bedolaga-telegram…/app/services/pricing_engine.py
T
Fringg 88369eec50 feat: add _calculate_servers_price (fixed fallback) and _calculate_traffic_price
_calculate_servers_price ALWAYS uses real server.price_kopeks even when
is_available=False or is_full=True, fixing the silent zero-price bug.
_calculate_traffic_price separates base from purchased GB to prevent
purchased top-ups from inflating the tier lookup.
2026-03-12 22:20:48 +03:00

130 lines
4.8 KiB
Python

from __future__ import annotations
import structlog
from dataclasses import dataclass, field
from app.config import settings
from app.database.crud.server_squad import get_server_squad_by_uuid
logger = structlog.get_logger()
@dataclass(frozen=True)
class RenewalPricing:
"""Immutable result of a renewal price calculation."""
base_price: int # kopeks
servers_price: int # kopeks
traffic_price: int # kopeks
devices_price: int # kopeks
promo_group_discount: int # kopeks deducted
promo_offer_discount: int # kopeks deducted
final_total: int # kopeks — amount to charge
period_days: int
is_tariff_mode: bool
breakdown: dict = field(default_factory=dict)
class PricingEngine:
"""Unified pricing engine for all subscription renewal calculations."""
@staticmethod
def apply_discount(amount_kopeks: int, percent: int) -> int:
"""Apply percentage discount with integer arithmetic.
Clamps percent to [0, 100]. Uses floor division."""
percent = max(0, min(100, percent))
discount = amount_kopeks * percent // 100
return amount_kopeks - discount
@staticmethod
def apply_stacked_discounts(
amount: int,
group_percent: int,
offer_percent: int,
) -> tuple[int, int, int]:
"""Apply promo-group discount, then promo-offer discount sequentially.
Returns (final_amount, group_discount_value, offer_discount_value)."""
after_group = PricingEngine.apply_discount(amount, group_percent)
group_discount_value = amount - after_group
after_offer = PricingEngine.apply_discount(after_group, offer_percent)
offer_discount_value = after_group - after_offer
return after_offer, group_discount_value, offer_discount_value
async def _calculate_servers_price(
self,
country_uuids: list[str],
db, # AsyncSession
*,
promo_group_id: int | None = None,
) -> tuple[int, list[dict]]:
"""Calculate total server price from connected squad UUIDs.
Unlike the old implementation, ALWAYS uses real price_kopeks
even when server is unavailable or full. Only orphaned UUIDs
(not found in DB) get price=0.
"""
total_price = 0
details: list[dict] = []
for uuid in country_uuids:
try:
server = await get_server_squad_by_uuid(db, uuid)
except Exception as e:
logger.error("Ошибка загрузки сервера", squad_uuid=uuid, error=str(e))
details.append({"uuid": uuid, "price": 0, "status": "error"})
continue
if server is None:
logger.error("Сервер не найден в БД", squad_uuid=uuid)
details.append({"uuid": uuid, "price": 0, "status": "not_found"})
continue
price = server.price_kopeks or 0
status = "available"
if not server.is_available:
status = "unavailable"
logger.warning(
"Сервер недоступен, используем реальную цену",
squad_uuid=uuid,
price_kopeks=price,
)
elif server.is_full:
status = "full"
logger.warning(
"Сервер переполнен, используем реальную цену",
squad_uuid=uuid,
price_kopeks=price,
)
elif promo_group_id is not None:
allowed_ids = [pg.id for pg in (server.allowed_promo_groups or [])]
if allowed_ids and promo_group_id not in allowed_ids:
status = "not_allowed"
logger.warning(
"Сервер недоступен для промогруппы, используем реальную цену",
squad_uuid=uuid,
promo_group_id=promo_group_id,
price_kopeks=price,
)
total_price += price
details.append({"uuid": uuid, "price": price, "status": status})
return total_price, details
def _calculate_traffic_price(
self,
traffic_limit_gb: int,
purchased_traffic_gb: int,
) -> int:
"""Calculate traffic price, separating base from purchased GB.
Prevents purchased top-ups from inflating the tier lookup."""
total_gb = traffic_limit_gb or 0
purchased_gb = purchased_traffic_gb or 0
base_gb = max(0, total_gb - purchased_gb)
base_price = settings.get_traffic_price(base_gb) if base_gb > 0 else 0
purchased_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0
return base_price + purchased_price