88369eec50
_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.
130 lines
4.8 KiB
Python
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
|