Merge pull request #1449 from Fr1ngg/dev4

Dev4
This commit is contained in:
Egor
2025-10-22 03:06:09 +03:00
committed by GitHub
6 changed files with 403 additions and 96 deletions

View File

@@ -2,7 +2,7 @@
import html
import logging
from datetime import datetime
from typing import Optional, Dict, Any
from typing import Any, Dict, Optional, Tuple
from aiogram import types, F
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
@@ -17,6 +17,7 @@ from app.services.subscription_purchase_service import SubscriptionPurchaseServi
from app.utils.decorators import error_handler
from app.states import SubscriptionStates
from app.utils.subscription_utils import get_display_subscription_link
from app.utils.pricing_utils import compute_simple_subscription_price
logger = logging.getLogger(__name__)
@@ -49,11 +50,7 @@ async def start_simple_subscription_purchase(
# Сохраняем параметры в состояние
await state.update_data(subscription_params=subscription_params)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
data = await state.get_data()
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
db,
@@ -62,39 +59,38 @@ async def start_simple_subscription_purchase(
user_id=db_user.id,
state_data=data,
)
period_days = subscription_params["period_days"]
recorded_price = getattr(settings, f"PRICE_{period_days}_DAYS", price_kopeks)
direct_purchase_min_balance = recorded_price
extra_components = []
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
if traffic_limit and traffic_limit > 0:
traffic_price = settings.get_traffic_price(traffic_limit)
direct_purchase_min_balance += traffic_price
extra_components.append(f"traffic={traffic_limit}GB->{traffic_price}")
device_limit = subscription_params.get("device_limit", 1)
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
devices_price = additional_devices * settings.PRICE_PER_DEVICE
direct_purchase_min_balance += devices_price
extra_components.append(f"devices+{additional_devices}->{devices_price}")
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base_price=%s | recorded_price=%s | extras=%s | total=%s | env_PRICE_30=%s",
db_user.id,
period_days,
price_kopeks,
recorded_price,
",".join(extra_components) if extra_components else "none",
direct_purchase_min_balance,
getattr(settings, "PRICE_30_DAYS", None),
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
subscription_params,
user=db_user,
resolved_squad_uuid=resolved_squad_uuid,
)
can_pay_from_balance = user_balance_kopeks >= direct_purchase_min_balance
period_days = subscription_params["period_days"]
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s",
db_user.id,
period_days,
price_breakdown.get("base_price", 0),
price_breakdown.get("traffic_price", 0),
price_breakdown.get("devices_price", 0),
price_breakdown.get("servers_price", 0),
price_breakdown.get("total_discount", 0),
price_kopeks,
",".join(price_breakdown.get("resolved_squad_uuids", []))
if price_breakdown.get("resolved_squad_uuids")
else "none",
)
can_pay_from_balance = user_balance_kopeks >= price_kopeks
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s",
db_user.id,
user_balance_kopeks,
direct_purchase_min_balance,
price_kopeks,
can_pay_from_balance,
)
@@ -158,27 +154,23 @@ async def start_simple_subscription_purchase(
await callback.answer()
def _calculate_simple_subscription_price(params: dict) -> int:
async def _calculate_simple_subscription_price(
db: AsyncSession,
params: dict,
*,
user: Optional[User] = None,
resolved_squad_uuid: Optional[str] = None,
) -> Tuple[int, Dict[str, Any]]:
"""Рассчитывает цену простой подписки."""
period_days = params.get("period_days", 30)
attr_name = f"PRICE_{period_days}_DAYS"
attr_value = getattr(settings, attr_name, None)
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_PRICE_FUNC | period=%s | attr=%s | attr_value=%s | base_price=%s",
period_days,
attr_name,
attr_value,
settings.BASE_SUBSCRIPTION_PRICE,
resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None
return await compute_simple_subscription_price(
db,
params,
user=user,
resolved_squad_uuids=resolved_uuids,
)
# Получаем цену для стандартного периода
if attr_value is not None:
return attr_value
else:
# Если нет цены для конкретного периода, используем базовую цену
return settings.BASE_SUBSCRIPTION_PRICE
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
"""Создает клавиатуру с методами оплаты для простой подписки."""
@@ -335,27 +327,22 @@ async def handle_simple_subscription_pay_with_balance(
)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
total_required = recorded_price
extras = []
traffic_limit = subscription_params.get("traffic_limit_gb", 0)
if traffic_limit and traffic_limit > 0:
traffic_price = settings.get_traffic_price(traffic_limit)
total_required += traffic_price
extras.append(f"traffic={traffic_limit}GB->{traffic_price}")
device_limit = subscription_params.get("device_limit", 1)
if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT:
additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT
devices_price = additional_devices * settings.PRICE_PER_DEVICE
total_required += devices_price
extras.append(f"devices+{additional_devices}->{devices_price}")
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
subscription_params,
user=db_user,
resolved_squad_uuid=resolved_squad_uuid,
)
total_required = price_kopeks
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base_price=%s | extras=%s | total_required=%s | balance=%s",
"SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s",
db_user.id,
subscription_params["period_days"],
price_kopeks,
",".join(extras) if extras else "none",
price_breakdown.get("base_price", 0),
price_breakdown.get("traffic_price", 0),
price_breakdown.get("devices_price", 0),
price_breakdown.get("servers_price", 0),
price_breakdown.get("total_discount", 0),
total_required,
getattr(db_user, "balance_kopeks", 0),
)
@@ -595,29 +582,38 @@ async def handle_simple_subscription_other_payment_methods(
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid(
db,
state,
subscription_params,
user_id=db_user.id,
state_data=data,
)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
subscription_params,
user=db_user,
resolved_squad_uuid=resolved_squad_uuid,
)
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks)
total_required = recorded_price
if subscription_params.get("traffic_limit_gb", 0) > 0:
total_required += settings.get_traffic_price(subscription_params["traffic_limit_gb"])
if subscription_params.get("device_limit", 1) > settings.DEFAULT_DEVICE_LIMIT:
additional_devices = subscription_params["device_limit"] - settings.DEFAULT_DEVICE_LIMIT
total_required += additional_devices * settings.PRICE_PER_DEVICE
can_pay_from_balance = user_balance_kopeks >= total_required
can_pay_from_balance = user_balance_kopeks >= price_kopeks
logger.warning(
"SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base_price=%s | total_required=%s | can_pay=%s",
"SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | can_pay=%s",
db_user.id,
user_balance_kopeks,
price_breakdown.get("base_price", 0),
price_breakdown.get("traffic_price", 0),
price_breakdown.get("devices_price", 0),
price_breakdown.get("servers_price", 0),
price_breakdown.get("total_discount", 0),
price_kopeks,
total_required,
can_pay_from_balance,
)
# Отображаем доступные методы оплаты
resolved_squad_uuid = data.get("resolved_squad_uuid")
server_label = _get_simple_subscription_server_label(
texts,
subscription_params,
@@ -677,11 +673,8 @@ async def handle_simple_subscription_payment_method(
await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True)
return
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
payment_method = callback.data.replace("simple_subscription_", "")
try:
payment_service = PaymentService(callback.bot)
@@ -693,6 +686,14 @@ async def handle_simple_subscription_payment_method(
state_data=data,
)
# Рассчитываем цену подписки
price_kopeks, _ = await _calculate_simple_subscription_price(
db,
subscription_params,
user=db_user,
resolved_squad_uuid=resolved_squad_uuid,
)
if payment_method == "stars":
# Оплата через Telegram Stars
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))

View File

@@ -64,6 +64,7 @@ from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
compute_simple_subscription_price,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
@@ -2412,7 +2413,22 @@ async def handle_simple_subscription_purchase(
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
subscription_params,
user=db_user,
resolved_squad_uuid=subscription_params.get("squad_uuid"),
)
logger.debug(
"SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s",
db_user.id,
price_kopeks,
price_breakdown.get("base_price", 0),
price_breakdown.get("traffic_price", 0),
price_breakdown.get("devices_price", 0),
price_breakdown.get("servers_price", 0),
price_breakdown.get("total_discount", 0),
)
traffic_text = (
"Безлимит"
if subscription_params["traffic_limit_gb"] == 0
@@ -2464,16 +2480,22 @@ async def handle_simple_subscription_purchase(
def _calculate_simple_subscription_price(params: dict) -> int:
async def _calculate_simple_subscription_price(
db: AsyncSession,
params: dict,
*,
user: Optional[User] = None,
resolved_squad_uuid: Optional[str] = None,
) -> Tuple[int, Dict[str, Any]]:
"""Рассчитывает цену простой подписки."""
period_days = params.get("period_days", 30)
# Получаем цену для стандартного периода
if hasattr(settings, f'PRICE_{period_days}_DAYS'):
return getattr(settings, f'PRICE_{period_days}_DAYS')
else:
# Если нет цены для конкретного периода, используем базовую цену
return settings.BASE_SUBSCRIPTION_PRICE
resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None
return await compute_simple_subscription_price(
db,
params,
user=user,
resolved_squad_uuids=resolved_uuids,
)
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
@@ -2564,7 +2586,22 @@ async def _extend_existing_subscription(
"traffic_limit_gb": traffic_limit_gb,
"squad_uuid": squad_uuid
}
price_kopeks = _calculate_simple_subscription_price(subscription_params)
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
subscription_params,
user=db_user,
resolved_squad_uuid=squad_uuid,
)
logger.debug(
"SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s",
db_user.id,
price_kopeks,
price_breakdown.get("base_price", 0),
price_breakdown.get("traffic_price", 0),
price_breakdown.get("devices_price", 0),
price_breakdown.get("servers_price", 0),
price_breakdown.get("total_discount", 0),
)
# Проверяем баланс пользователя
if db_user.balance_kopeks < price_kopeks:

View File

@@ -1,7 +1,14 @@
from datetime import datetime, timedelta
from typing import Tuple
from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
if TYPE_CHECKING: # pragma: no cover
from app.database.models import User, PromoGroup
logger = logging.getLogger(__name__)
@@ -68,6 +75,218 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
return discounted_amount, discount_value
def resolve_discount_percent(
user: Optional["User"],
promo_group: Optional["PromoGroup"],
category: str,
*,
period_days: Optional[int] = 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: Optional[Sequence[str]] = 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: Optional["PromoGroup"] = 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 = getattr(user, "promo_group", None)
period_discount_percent = resolve_discount_percent(
user,
promo_group,
"period",
period_days=period_days,
)
base_discount = base_price_original * period_discount_percent // 100
discounted_base = base_price_original - base_discount
traffic_discount_percent = resolve_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount = traffic_price_original * traffic_discount_percent // 100
discounted_traffic = traffic_price_original - traffic_discount
devices_discount_percent = resolve_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount = devices_price_original * devices_discount_percent // 100
discounted_devices = devices_price_original - devices_discount
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=%s",
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=%s | available=%s | full=%s",
squad_uuid,
server.is_available,
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 format_period_description(days: int, language: str = "ru") -> str:
months = calculate_months_from_days(days)

View File

@@ -1,5 +1,7 @@
"""Глобальные фикстуры и настройки окружения для тестов."""
import asyncio
import inspect
import os
import sys
import types
@@ -149,3 +151,50 @@ if "yookassa" not in sys.modules:
def fixed_datetime() -> datetime:
"""Возвращает фиксированную отметку времени для воспроизводимых проверок."""
return datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
def pytest_configure(config: pytest.Config) -> None:
"""Регистрируем маркеры для асинхронных тестов."""
config.addinivalue_line(
"markers",
"asyncio: запуск асинхронного теста через встроенный цикл событий",
)
config.addinivalue_line(
"markers",
"anyio: запуск асинхронного теста через встроенный цикл событий",
)
def _unwrap_test(obj): # noqa: ANN001 - вспомогательная функция для определения coroutine
"""Возвращает исходную функцию, снимая обёртки pytest и декораторов."""
unwrapped = obj
while hasattr(unwrapped, "__wrapped__"):
unwrapped = unwrapped.__wrapped__
return unwrapped
@pytest.hookimpl(tryfirst=True)
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
"""Позволяет запускать async def тесты без дополнительных плагинов."""
test_func = _unwrap_test(pyfuncitem.obj)
if not inspect.iscoroutinefunction(test_func):
return None
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
signature = inspect.signature(test_func)
call_kwargs = {
name: value
for name, value in pyfuncitem.funcargs.items()
if name in signature.parameters
}
loop.run_until_complete(pyfuncitem.obj(**call_kwargs))
finally:
asyncio.set_event_loop(None)
loop.close()
return True

View File

@@ -77,7 +77,7 @@ async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None:
assert client.calls and client.calls[0]["amount"] == Decimal("500.00")
assert client.calls[0]["shop_id"] == "shop42"
assert client.calls[0]["description"] == "Пополнение"
assert client.calls[0]["custom"] == json.dumps({"extra": "value"}, ensure_ascii=False, separators=(",", ":"))
assert client.calls[0]["custom"] == {"extra": "value"}
assert client.calls[0]["payment_method"] == "BANK_CARD"

View File

@@ -112,6 +112,7 @@ async def test_delete_user_cart_not_found(user_cart_service):
assert result is False
@pytest.mark.asyncio
async def test_has_user_cart(user_cart_service, mock_redis):
"""Тест проверки наличия корзины пользователя"""