Files
remnawave-bedolaga-telegram…/app/utils/pricing_utils.py
T
Fringg fcdeff1ee5 fix: migrate pricing to days-based proration, fix promo revenue leaks, fix admin panel bugs
- Migrate all addon pricing (devices, traffic, countries) from months-based to days-based proration
- Remove get_remaining_months() utility, use days_left / 30 consistently
- Fix promo/campaign balance bonuses counted as revenue in reports (add REAL_PAYMENT_METHODS filter)
- Fix partner stats, campaign stats, miniapp stats, referral fraud detection promo deposit leaks
- Fix admin balance history showing deductions with + sign (use -abs for expense types)
- Fix promo offer deactivation returning 400 for non-promocode offers
- Fix daily tariff renewal requesting 30-day renewal instead of 1-day purchase
2026-03-10 03:31:07 +03:00

358 lines
12 KiB
Python

from collections.abc import Sequence
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Optional
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
if TYPE_CHECKING: # pragma: no cover
from app.database.models import PromoGroup, User
logger = structlog.get_logger(__name__)
def calculate_months_from_days(days: int) -> int:
return max(1, round(days / 30))
def calculate_period_multiplier(period_days: int) -> tuple[int, float]:
exact_months = period_days / 30
months_count = max(1, round(exact_months))
logger.debug(
'Период дней точных месяцев ≈ месяцев для расчета',
period_days=period_days,
exact_months=round(exact_months, 2),
months_count=months_count,
)
return months_count, exact_months
def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_days: int = 30) -> tuple[int, int]:
"""Calculate prorated price based on remaining days.
Returns:
tuple of (total_price_kopeks, days_charged)
"""
now = datetime.now(UTC)
days_remaining = max(1, (end_date - now).days)
days_to_charge = max(min_charge_days, days_remaining)
total_price = int(monthly_price * days_to_charge / 30)
if monthly_price > 0:
total_price = max(100, total_price) # Минимум 1 рубль
logger.debug(
'Расчет пропорциональной цены: ₽/мес × дн./30 = ₽',
monthly_price=monthly_price / 100,
days_to_charge=days_to_charge,
total_price=total_price / 100,
)
return total_price, days_to_charge
def apply_percentage_discount(amount: int, percent: int) -> tuple[int, int]:
if amount <= 0 or percent <= 0:
return amount, 0
clamped_percent = max(0, min(100, percent))
discount_value = amount * clamped_percent // 100
discounted_amount = amount - discount_value
# Round the discounted price up to the nearest full ruble (100 kopeks)
# to avoid undercharging users because of fractional kopeks.
if discount_value >= 100 and discounted_amount % 100:
discounted_amount += 100 - (discounted_amount % 100)
discounted_amount = min(discounted_amount, amount)
discount_value = amount - discounted_amount
logger.debug(
'Применена скидка %: → (скидка)',
clamped_percent=clamped_percent,
amount=amount,
discounted_amount=discounted_amount,
discount_value=discount_value,
)
return discounted_amount, discount_value
def resolve_discount_percent(
user: Optional['User'],
promo_group: Optional['PromoGroup'],
category: str,
*,
period_days: int | None = None,
) -> int:
"""Определяет размер скидки для указанной категории."""
if user is not None:
try:
return user.get_promo_discount(category, period_days)
except AttributeError: # pragma: no cover - defensive guard
pass
if promo_group is not None:
return promo_group.get_discount_percent(category, period_days)
return 0
async def compute_simple_subscription_price(
db: AsyncSession,
params: dict[str, Any],
*,
user: Optional['User'] = None,
resolved_squad_uuids: Sequence[str] | None = None,
) -> tuple[int, dict[str, Any]]:
"""Вычисляет стоимость простой подписки с учетом всех доплат и скидок."""
period_days = int(params.get('period_days', 30) or 30)
attr_name = f'PRICE_{period_days}_DAYS'
base_price_original = getattr(settings, attr_name, settings.BASE_SUBSCRIPTION_PRICE)
traffic_limit_raw = params.get('traffic_limit_gb')
try:
traffic_limit = int(traffic_limit_raw) if traffic_limit_raw is not None else None
except (TypeError, ValueError): # pragma: no cover - defensive conversion
traffic_limit = None
if traffic_limit is None or traffic_limit <= 0:
# Default simple subscriptions already include unlimited traffic.
traffic_price_original = 0
else:
traffic_price_original = settings.get_traffic_price(traffic_limit)
device_limit_raw = params.get('device_limit', settings.DEFAULT_DEVICE_LIMIT)
try:
device_limit = int(device_limit_raw)
except (TypeError, ValueError): # pragma: no cover - defensive conversion
device_limit = settings.DEFAULT_DEVICE_LIMIT
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_original = additional_devices * settings.PRICE_PER_DEVICE
promo_group: PromoGroup | None = params.get('promo_group')
if promo_group is None:
promo_group_id = params.get('promo_group_id')
if promo_group_id:
from app.database.crud.promo_group import get_promo_group_by_id
promo_group = await get_promo_group_by_id(db, int(promo_group_id))
if promo_group is None and user is not None:
promo_group = user.get_primary_promo_group()
period_discount_percent = resolve_discount_percent(
user,
promo_group,
'period',
period_days=period_days,
)
base_discount = base_price_original * period_discount_percent // 100
traffic_discount_percent = resolve_discount_percent(
user,
promo_group,
'traffic',
period_days=period_days,
)
traffic_discount = traffic_price_original * traffic_discount_percent // 100
devices_discount_percent = resolve_discount_percent(
user,
promo_group,
'devices',
period_days=period_days,
)
devices_discount = devices_price_original * devices_discount_percent // 100
servers_discount_percent = resolve_discount_percent(
user,
promo_group,
'servers',
period_days=period_days,
)
resolved_uuids: list[str] = []
if resolved_squad_uuids:
resolved_uuids.extend([uuid for uuid in resolved_squad_uuids if uuid])
else:
raw_squad = params.get('squad_uuid')
if isinstance(raw_squad, (list, tuple, set)):
resolved_uuids.extend([str(uuid) for uuid in raw_squad if uuid])
elif raw_squad:
resolved_uuids.append(str(raw_squad))
from app.database.crud.server_squad import get_server_squad_by_uuid
server_breakdown: list[dict[str, Any]] = []
servers_price_original = 0
servers_discount_total = 0
for squad_uuid in resolved_uuids:
server = await get_server_squad_by_uuid(db, squad_uuid)
if not server:
logger.warning('SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad', squad_uuid=squad_uuid)
server_breakdown.append(
{
'uuid': squad_uuid,
'name': None,
'available': False,
'original_price': 0,
'discount': 0,
'final_price': 0,
}
)
continue
if not server.is_available or server.is_full:
logger.warning(
'SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad= | available= | full',
squad_uuid=squad_uuid,
is_available=server.is_available,
is_full=server.is_full,
)
server_breakdown.append(
{
'uuid': squad_uuid,
'name': server.display_name,
'available': False,
'original_price': 0,
'discount': 0,
'final_price': 0,
}
)
continue
original_price = server.price_kopeks
discount_value = original_price * servers_discount_percent // 100
final_price = original_price - discount_value
servers_price_original += original_price
servers_discount_total += discount_value
server_breakdown.append(
{
'uuid': squad_uuid,
'name': server.display_name,
'available': True,
'original_price': original_price,
'discount': discount_value,
'final_price': final_price,
}
)
total_before_discount = (
base_price_original + traffic_price_original + devices_price_original + servers_price_original
)
total_discount = base_discount + traffic_discount + devices_discount + servers_discount_total
total_price = max(0, total_before_discount - total_discount)
breakdown = {
'base_price': base_price_original,
'base_discount': base_discount,
'traffic_price': traffic_price_original,
'traffic_discount': traffic_discount,
'devices_price': devices_price_original,
'devices_discount': devices_discount,
'servers_price': servers_price_original,
'servers_discount': servers_discount_total,
'servers_final': sum(item['final_price'] for item in server_breakdown),
'server_details': server_breakdown,
'total_before_discount': total_before_discount,
'total_discount': total_discount,
'resolved_squad_uuids': resolved_uuids,
'applied_promo_group_id': getattr(promo_group, 'id', None) if promo_group else None,
'period_discount_percent': period_discount_percent,
'traffic_discount_percent': traffic_discount_percent,
'devices_discount_percent': devices_discount_percent,
'servers_discount_percent': servers_discount_percent,
}
return total_price, breakdown
def _pluralize_days_ru(n: int) -> str:
"""Склонение слова 'день' по числу: 1 день, 2 дня, 5 дней."""
mod100 = n % 100
mod10 = n % 10
if 11 <= mod100 <= 19:
return 'дней'
if mod10 == 1:
return 'день'
if 2 <= mod10 <= 4:
return 'дня'
return 'дней'
def format_period_description(days: int, language: str = 'ru') -> str:
language_code = (language or 'ru').split('-')[0].lower()
if language_code in {'ru', 'fa'}:
if days == 30:
return '1 месяц'
if days == 60:
return '2 месяца'
if days == 90:
return '3 месяца'
if days == 180:
return '6 месяцев'
if days == 360:
return '12 месяцев'
return f'{days} {_pluralize_days_ru(days)}'
if days == 30:
return '1 month'
if days == 60:
return '2 months'
if days == 90:
return '3 months'
if days == 180:
return '6 months'
if days == 360:
return '12 months'
day_word = 'day' if days == 1 else 'days'
return f'{days} {day_word}'
def validate_pricing_calculation(base_price: int, monthly_additions: int, months: int, total_calculated: int) -> bool:
expected_total = base_price + (monthly_additions * months)
is_valid = expected_total == total_calculated
if not is_valid:
logger.warning(
'Несоответствие в расчете цены: ожидалось ₽, получено ₽',
expected_total=expected_total / 100,
total_calculated=total_calculated / 100,
)
logger.warning(
'Детали: базовая цена ₽ + месячные дополнения ₽ × мес',
base_price=base_price / 100,
monthly_additions=monthly_additions / 100,
months=months,
)
return is_valid
STANDARD_PERIODS = {
14: {'months': 0.5, 'display_ru': '2 недели', 'display_en': '2 weeks'},
30: {'months': 1, 'display_ru': '1 месяц', 'display_en': '1 month'},
60: {'months': 2, 'display_ru': '2 месяца', 'display_en': '2 months'},
90: {'months': 3, 'display_ru': '3 месяца', 'display_en': '3 months'},
180: {'months': 6, 'display_ru': '6 месяцев', 'display_en': '6 months'},
360: {'months': 12, 'display_ru': '1 год', 'display_en': '1 year'},
}
def get_period_info(days: int) -> dict:
return STANDARD_PERIODS.get(days)