diff --git a/app/webapi/routes/miniapp/subscription.py b/app/webapi/routes/miniapp.py similarity index 57% rename from app/webapi/routes/miniapp/subscription.py rename to app/webapi/routes/miniapp.py index 68908354..e690f522 100644 --- a/app/webapi/routes/miniapp/subscription.py +++ b/app/webapi/routes/miniapp.py @@ -2,8 +2,10 @@ from __future__ import annotations import logging import re -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +import math +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR from datetime import datetime, timedelta, timezone +from uuid import uuid4 from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union from aiogram import Bot @@ -15,10 +17,13 @@ from sqlalchemy.orm import selectinload from app.config import settings from app.database.crud.discount_offer import ( get_latest_claimed_offer_for_user, + get_offer_by_id, list_active_discount_offers_for_user, + mark_offer_claimed, ) from app.database.crud.promo_group import get_auto_assign_promo_groups from app.database.crud.rules import get_rules_by_language +from app.database.crud.promo_offer_template import get_promo_offer_template_by_id from app.database.crud.server_squad import ( add_user_to_servers, get_available_server_squads, @@ -41,10 +46,12 @@ from app.database.crud.transaction import ( from app.database.crud.user import get_user_by_telegram_id, subtract_user_balance from app.database.models import ( PromoGroup, + PromoOfferTemplate, Subscription, SubscriptionTemporaryAccess, Transaction, TransactionType, + PaymentMethod, User, ) from app.services.admin_notification_service import AdminNotificationService @@ -55,12 +62,17 @@ from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, ) +from app.services.payment_service import PaymentService, get_wata_payment_by_link_id +from app.services.promo_offer_service import promo_offer_service +from app.services.promocode_service import PromoCodeService from app.services.subscription_service import SubscriptionService from app.services.subscription_purchase_service import ( purchase_service, PurchaseBalanceError, PurchaseValidationError, ) +from app.services.tribute_service import TributeService +from app.utils.currency_converter import currency_converter from app.utils.subscription_utils import get_happ_cryptolink_redirect_link from app.utils.telegram_webapp import ( TelegramWebAppAuthError, @@ -80,8 +92,8 @@ from app.utils.pricing_utils import ( ) from app.utils.promo_offer import get_user_active_promo_discount_percent -from ...dependencies import get_db_session -from ...schemas.miniapp import ( +from ..dependencies import get_db_session +from ..schemas.miniapp import ( MiniAppAutoPromoGroupLevel, MiniAppConnectedServer, MiniAppDevice, @@ -90,7 +102,22 @@ from ...schemas.miniapp import ( MiniAppFaq, MiniAppFaqItem, MiniAppLegalDocuments, + MiniAppPaymentCreateRequest, + MiniAppPaymentCreateResponse, + MiniAppPaymentMethod, + MiniAppPaymentMethodsRequest, + MiniAppPaymentMethodsResponse, + MiniAppPaymentStatusQuery, + MiniAppPaymentStatusRequest, + MiniAppPaymentStatusResponse, + MiniAppPaymentStatusResult, + MiniAppPromoCode, + MiniAppPromoCodeActivationRequest, + MiniAppPromoCodeActivationResponse, MiniAppPromoGroup, + MiniAppPromoOffer, + MiniAppPromoOfferClaimRequest, + MiniAppPromoOfferClaimResponse, MiniAppReferralInfo, MiniAppReferralItem, MiniAppReferralList, @@ -135,22 +162,12 @@ from ...schemas.miniapp import ( MiniAppSubscriptionRenewalResponse, ) -from .promo import ( - ActiveOfferContext, - build_promo_offer_models, - extract_promo_discounts, - find_active_test_access_offers, - resolve_connected_servers, -) - logger = logging.getLogger(__name__) router = APIRouter() - -_PERIOD_ID_PATTERN = re.compile(r"(\d+)") -_AUTOPAY_DEFAULT_DAY_OPTIONS = (1, 3, 7, 14) +promo_code_service = PromoCodeService() async def _with_admin_notification_service( @@ -174,6 +191,203 @@ async def _with_admin_notification_service( await bot.session.close() +_CRYPTOBOT_MIN_USD = 1.0 +_CRYPTOBOT_MAX_USD = 1000.0 +_CRYPTOBOT_FALLBACK_RATE = 95.0 + +_DECIMAL_ONE_HUNDRED = Decimal(100) +_DECIMAL_CENT = Decimal("0.01") + +_PAYMENT_SUCCESS_STATUSES = { + "paid", + "success", + "succeeded", + "completed", + "captured", + "done", + "overpaid", +} +_PAYMENT_FAILURE_STATUSES = { + "fail", + "failed", + "canceled", + "cancelled", + "declined", + "expired", + "rejected", + "error", + "refunded", + "chargeback", +} + + +_PERIOD_ID_PATTERN = re.compile(r"(\d+)") + + +_AUTOPAY_DEFAULT_DAY_OPTIONS = (1, 3, 7, 14) + + +def _normalize_autopay_days(value: Optional[Any]) -> Optional[int]: + if value is None: + return None + try: + numeric = int(value) + except (TypeError, ValueError): + return None + return numeric if numeric >= 0 else None + + +def _get_autopay_day_options(subscription: Optional[Subscription]) -> List[int]: + options: set[int] = set() + for candidate in _AUTOPAY_DEFAULT_DAY_OPTIONS: + normalized = _normalize_autopay_days(candidate) + if normalized is not None: + options.add(normalized) + + default_setting = _normalize_autopay_days( + getattr(settings, "DEFAULT_AUTOPAY_DAYS_BEFORE", None) + ) + if default_setting is not None: + options.add(default_setting) + + if subscription is not None: + current = _normalize_autopay_days( + getattr(subscription, "autopay_days_before", None) + ) + if current is not None: + options.add(current) + + return sorted(options) + + +def _build_autopay_payload( + subscription: Optional[Subscription], +) -> Optional[MiniAppSubscriptionAutopay]: + if subscription is None: + return None + + enabled = bool(getattr(subscription, "autopay_enabled", False)) + days_before = _normalize_autopay_days( + getattr(subscription, "autopay_days_before", None) + ) + options = _get_autopay_day_options(subscription) + + default_days = days_before + if default_days is None: + default_days = _normalize_autopay_days( + getattr(settings, "DEFAULT_AUTOPAY_DAYS_BEFORE", None) + ) + if default_days is None and options: + default_days = options[0] + + autopay_kwargs: Dict[str, Any] = { + "enabled": enabled, + "autopay_enabled": enabled, + "days_before": days_before, + "autopay_days_before": days_before, + "default_days_before": default_days, + "autopay_days_options": options, + "days_options": options, + "options": options, + "available_days": options, + "availableDays": options, + "autopayEnabled": enabled, + "autopayDaysBefore": days_before, + "autopayDaysOptions": options, + "daysBefore": days_before, + "daysOptions": options, + "defaultDaysBefore": default_days, + } + + return MiniAppSubscriptionAutopay(**autopay_kwargs) + + +def _autopay_response_extras( + enabled: bool, + days_before: Optional[int], + options: List[int], + autopay_payload: Optional[MiniAppSubscriptionAutopay], +) -> Dict[str, Any]: + extras: Dict[str, Any] = { + "autopayEnabled": enabled, + "autopayDaysBefore": days_before, + "autopayDaysOptions": options, + } + if days_before is not None: + extras["daysBefore"] = days_before + if options: + extras["daysOptions"] = options + if autopay_payload is not None: + extras["autopaySettings"] = autopay_payload + return extras + + +async def _get_usd_to_rub_rate() -> float: + try: + rate = await currency_converter.get_usd_to_rub_rate() + except Exception: + rate = 0.0 + if not rate or rate <= 0: + rate = _CRYPTOBOT_FALLBACK_RATE + return float(rate) + + +def _compute_cryptobot_limits(rate: float) -> Tuple[int, int]: + min_kopeks = max(1, int(math.ceil(rate * _CRYPTOBOT_MIN_USD * 100))) + max_kopeks = int(math.floor(rate * _CRYPTOBOT_MAX_USD * 100)) + if max_kopeks < min_kopeks: + max_kopeks = min_kopeks + return min_kopeks, max_kopeks + + +def _current_request_timestamp() -> str: + return datetime.utcnow().replace(microsecond=0).isoformat() + + +def _compute_stars_min_amount() -> Optional[int]: + try: + rate = Decimal(str(settings.get_stars_rate())) + except (InvalidOperation, TypeError): + return None + + if rate <= 0: + return None + + return int((rate * _DECIMAL_ONE_HUNDRED).to_integral_value(rounding=ROUND_HALF_UP)) + + +def _normalize_stars_amount(amount_kopeks: int) -> Tuple[int, int]: + try: + rate = Decimal(str(settings.get_stars_rate())) + except (InvalidOperation, TypeError): + raise ValueError("Stars rate is not configured") + + if rate <= 0: + raise ValueError("Stars rate must be positive") + + amount_rubles = Decimal(amount_kopeks) / _DECIMAL_ONE_HUNDRED + stars_amount = int((amount_rubles / rate).to_integral_value(rounding=ROUND_FLOOR)) + if stars_amount <= 0: + stars_amount = 1 + + normalized_rubles = (Decimal(stars_amount) * rate).quantize( + _DECIMAL_CENT, + rounding=ROUND_HALF_UP, + ) + normalized_amount_kopeks = int( + (normalized_rubles * _DECIMAL_ONE_HUNDRED).to_integral_value( + rounding=ROUND_HALF_UP + ) + ) + + return stars_amount, normalized_amount_kopeks + + +def _build_balance_invoice_payload(user_id: int, amount_kopeks: int) -> str: + suffix = uuid4().hex[:8] + return f"balance_{user_id}_{amount_kopeks}_{suffix}" + + def _merge_purchase_selection_from_request( payload: Union[ "MiniAppSubscriptionPurchasePreviewRequest", @@ -213,6 +427,102 @@ def _merge_purchase_selection_from_request( return base +def _parse_client_timestamp(value: Optional[Union[str, int, float]]) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, (int, float)): + try: + timestamp = float(value) + except (TypeError, ValueError): + return None + if timestamp > 1e12: + timestamp /= 1000.0 + try: + return datetime.fromtimestamp(timestamp, tz=timezone.utc).replace(tzinfo=None) + except (OverflowError, OSError, ValueError): + return None + if isinstance(value, str): + normalized = value.strip() + if not normalized: + return None + if normalized.isdigit(): + return _parse_client_timestamp(int(normalized)) + for suffix in ("Z", "z"): + if normalized.endswith(suffix): + normalized = normalized[:-1] + "+00:00" + break + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo: + return parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed + return None + + +async def _find_recent_deposit( + db: AsyncSession, + *, + user_id: int, + payment_method: PaymentMethod, + amount_kopeks: Optional[int], + started_at: Optional[datetime], + tolerance: timedelta = timedelta(minutes=5), +) -> Optional[Transaction]: + def _transaction_matches_started_at( + transaction: Transaction, + reference: Optional[datetime], + ) -> bool: + if not reference: + return True + timestamp = transaction.completed_at or transaction.created_at + if not timestamp: + return False + if timestamp.tzinfo: + timestamp = timestamp.astimezone(timezone.utc).replace(tzinfo=None) + return timestamp >= reference + + query = ( + select(Transaction) + .where( + Transaction.user_id == user_id, + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.payment_method == payment_method.value, + ) + .order_by(Transaction.created_at.desc()) + .limit(1) + ) + + if amount_kopeks is not None: + query = query.where(Transaction.amount_kopeks == amount_kopeks) + if started_at: + query = query.where(Transaction.created_at >= started_at - tolerance) + + result = await db.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + return None + + if not _transaction_matches_started_at(transaction, started_at): + return None + + return transaction + + +def _classify_status(status: Optional[str], is_paid: bool) -> str: + if is_paid: + return "paid" + normalized = (status or "").strip().lower() + if not normalized: + return "pending" + if normalized in _PAYMENT_SUCCESS_STATUSES: + return "paid" + if normalized in _PAYMENT_FAILURE_STATUSES: + return "failed" + return "pending" + def _format_gb(value: Optional[float]) -> float: if value is None: return 0.0 @@ -305,6 +615,1796 @@ def _normalize_amount_kopeks( return normalized if normalized >= 0 else None +@router.post( + "/payments/methods", + response_model=MiniAppPaymentMethodsResponse, +) +async def get_payment_methods( + payload: MiniAppPaymentMethodsRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppPaymentMethodsResponse: + _, _ = await _resolve_user_from_init_data(db, payload.init_data) + + methods: List[MiniAppPaymentMethod] = [] + + if settings.TELEGRAM_STARS_ENABLED: + stars_min_amount = _compute_stars_min_amount() + methods.append( + MiniAppPaymentMethod( + id="stars", + icon="⭐", + requires_amount=True, + currency="RUB", + min_amount_kopeks=stars_min_amount, + amount_step_kopeks=stars_min_amount, + ) + ) + + if settings.is_yookassa_enabled(): + if getattr(settings, "YOOKASSA_SBP_ENABLED", False): + methods.append( + MiniAppPaymentMethod( + id="yookassa_sbp", + icon="🏦", + requires_amount=True, + currency="RUB", + min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, + ) + ) + + methods.append( + MiniAppPaymentMethod( + id="yookassa", + icon="πŸ’³", + requires_amount=True, + currency="RUB", + min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, + ) + ) + + if settings.is_mulenpay_enabled(): + methods.append( + MiniAppPaymentMethod( + id="mulenpay", + icon="πŸ’³", + requires_amount=True, + currency="RUB", + min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS, + ) + ) + + if settings.is_pal24_enabled(): + methods.append( + MiniAppPaymentMethod( + id="pal24", + icon="🏦", + requires_amount=True, + currency="RUB", + min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, + ) + ) + + if settings.is_wata_enabled(): + methods.append( + MiniAppPaymentMethod( + id="wata", + icon="🌊", + requires_amount=True, + currency="RUB", + min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS, + ) + ) + + if settings.is_cryptobot_enabled(): + rate = await _get_usd_to_rub_rate() + min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate) + methods.append( + MiniAppPaymentMethod( + id="cryptobot", + icon="πŸͺ™", + requires_amount=True, + currency="RUB", + min_amount_kopeks=min_amount_kopeks, + max_amount_kopeks=max_amount_kopeks, + ) + ) + + if settings.TRIBUTE_ENABLED: + methods.append( + MiniAppPaymentMethod( + id="tribute", + icon="πŸ’Ž", + requires_amount=False, + currency="RUB", + ) + ) + + order_map = { + "stars": 1, + "yookassa_sbp": 2, + "yookassa": 3, + "mulenpay": 4, + "pal24": 5, + "wata": 6, + "cryptobot": 7, + "tribute": 8, + } + methods.sort(key=lambda item: order_map.get(item.id, 99)) + + return MiniAppPaymentMethodsResponse(methods=methods) + + +@router.post( + "/payments/create", + response_model=MiniAppPaymentCreateResponse, +) +async def create_payment_link( + payload: MiniAppPaymentCreateRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppPaymentCreateResponse: + user, _ = await _resolve_user_from_init_data(db, payload.init_data) + + method = (payload.method or "").strip().lower() + if not method: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Payment method is required", + ) + + amount_kopeks = _normalize_amount_kopeks( + payload.amount_rubles, + payload.amount_kopeks, + ) + + if method == "stars": + if not settings.TELEGRAM_STARS_ENABLED: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + if not settings.BOT_TOKEN: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured") + + requested_amount_kopeks = amount_kopeks + try: + stars_amount, amount_kopeks = _normalize_stars_amount(amount_kopeks) + except ValueError as exc: + logger.error("Failed to normalize Stars amount: %s", exc) + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to prepare Stars payment", + ) from exc + + bot = Bot(token=settings.BOT_TOKEN) + invoice_payload = _build_balance_invoice_payload(user.id, amount_kopeks) + try: + payment_service = PaymentService(bot) + invoice_link = await payment_service.create_stars_invoice( + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + payload=invoice_payload, + stars_amount=stars_amount, + ) + finally: + await bot.session.close() + + if not invoice_link: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create invoice") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=invoice_link, + amount_kopeks=amount_kopeks, + extra={ + "invoice_payload": invoice_payload, + "requested_at": _current_request_timestamp(), + "stars_amount": stars_amount, + "requested_amount_kopeks": requested_amount_kopeks, + }, + ) + + if method == "yookassa_sbp": + if not settings.is_yookassa_enabled() or not getattr(settings, "YOOKASSA_SBP_ENABLED", False): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + + payment_service = PaymentService() + result = await payment_service.create_yookassa_sbp_payment( + db=db, + user_id=user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + ) + confirmation_url = result.get("confirmation_url") if result else None + if not result or not confirmation_url: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + extra: dict[str, Any] = { + "local_payment_id": result.get("local_payment_id"), + "payment_id": result.get("yookassa_payment_id"), + "status": result.get("status"), + "requested_at": _current_request_timestamp(), + } + confirmation_token = result.get("confirmation_token") + if confirmation_token: + extra["confirmation_token"] = confirmation_token + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=confirmation_url, + amount_kopeks=amount_kopeks, + extra=extra, + ) + + if method == "yookassa": + if not settings.is_yookassa_enabled(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + + payment_service = PaymentService() + result = await payment_service.create_yookassa_payment( + db=db, + user_id=user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + ) + if not result or not result.get("confirmation_url"): + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=result["confirmation_url"], + amount_kopeks=amount_kopeks, + extra={ + "local_payment_id": result.get("local_payment_id"), + "payment_id": result.get("yookassa_payment_id"), + "status": result.get("status"), + "requested_at": _current_request_timestamp(), + }, + ) + + if method == "mulenpay": + if not settings.is_mulenpay_enabled(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + + payment_service = PaymentService() + result = await payment_service.create_mulenpay_payment( + db=db, + user_id=user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + language=user.language, + ) + if not result or not result.get("payment_url"): + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=result["payment_url"], + amount_kopeks=amount_kopeks, + extra={ + "local_payment_id": result.get("local_payment_id"), + "payment_id": result.get("mulen_payment_id"), + "requested_at": _current_request_timestamp(), + }, + ) + + if method == "wata": + if not settings.is_wata_enabled(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + + payment_service = PaymentService() + result = await payment_service.create_wata_payment( + db=db, + user_id=user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + language=user.language, + ) + payment_url = result.get("payment_url") if result else None + if not result or not payment_url: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=payment_url, + amount_kopeks=amount_kopeks, + extra={ + "local_payment_id": result.get("local_payment_id"), + "payment_link_id": result.get("payment_link_id"), + "payment_id": result.get("payment_link_id"), + "status": result.get("status"), + "order_id": result.get("order_id"), + "requested_at": _current_request_timestamp(), + }, + ) + + if method == "pal24": + if not settings.is_pal24_enabled(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + + option = (payload.payment_option or "").strip().lower() + if option not in {"card", "sbp"}: + option = "sbp" + provider_method = "CARD" if option == "card" else "SBP" + + payment_service = PaymentService() + result = await payment_service.create_pal24_payment( + db=db, + user_id=user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + language=user.language or settings.DEFAULT_LANGUAGE, + payment_method=provider_method, + ) + if not result: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + preferred_urls: List[Optional[str]] = [] + if option == "sbp": + preferred_urls.append(result.get("sbp_url")) + elif option == "card": + preferred_urls.append(result.get("card_url")) + preferred_urls.extend( + [ + result.get("link_url"), + result.get("link_page_url"), + result.get("payment_url"), + result.get("transfer_url"), + ] + ) + payment_url = next((url for url in preferred_urls if url), None) + if not payment_url: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=payment_url, + amount_kopeks=amount_kopeks, + extra={ + "local_payment_id": result.get("local_payment_id"), + "bill_id": result.get("bill_id"), + "order_id": result.get("order_id"), + "payment_method": result.get("payment_method") or provider_method, + "sbp_url": result.get("sbp_url"), + "card_url": result.get("card_url"), + "link_url": result.get("link_url"), + "link_page_url": result.get("link_page_url"), + "transfer_url": result.get("transfer_url"), + "selected_option": option, + "requested_at": _current_request_timestamp(), + }, + ) + + if method == "cryptobot": + if not settings.is_cryptobot_enabled(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + rate = await _get_usd_to_rub_rate() + min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate) + if amount_kopeks < min_amount_kopeks: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)", + ) + if amount_kopeks > max_amount_kopeks: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)", + ) + + try: + amount_usd = float( + (Decimal(amount_kopeks) / Decimal(100) / Decimal(str(rate))) + .quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) + except (InvalidOperation, ValueError): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Unable to convert amount to USD", + ) + + payment_service = PaymentService() + result = await payment_service.create_cryptobot_payment( + db=db, + user_id=user.id, + amount_usd=amount_usd, + asset=settings.CRYPTOBOT_DEFAULT_ASSET, + description=settings.get_balance_payment_description(amount_kopeks), + payload=f"balance_{user.id}_{amount_kopeks}", + ) + if not result: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + payment_url = ( + result.get("bot_invoice_url") + or result.get("mini_app_invoice_url") + or result.get("web_app_invoice_url") + ) + if not payment_url: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=payment_url, + amount_kopeks=amount_kopeks, + extra={ + "local_payment_id": result.get("local_payment_id"), + "invoice_id": result.get("invoice_id"), + "amount_usd": amount_usd, + "rate": rate, + "requested_at": _current_request_timestamp(), + }, + ) + + if method == "tribute": + if not settings.TRIBUTE_ENABLED: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if not settings.BOT_TOKEN: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured") + + bot = Bot(token=settings.BOT_TOKEN) + try: + tribute_service = TributeService(bot) + payment_url = await tribute_service.create_payment_link( + user_id=user.telegram_id, + amount_kopeks=amount_kopeks or 0, + description=settings.get_balance_payment_description(amount_kopeks or 0), + ) + finally: + await bot.session.close() + + if not payment_url: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=payment_url, + amount_kopeks=amount_kopeks, + extra={ + "requested_at": _current_request_timestamp(), + }, + ) + + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unknown payment method") + + +@router.post( + "/payments/status", + response_model=MiniAppPaymentStatusResponse, +) +async def get_payment_statuses( + payload: MiniAppPaymentStatusRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppPaymentStatusResponse: + user, _ = await _resolve_user_from_init_data(db, payload.init_data) + + entries = payload.payments or [] + if not entries: + return MiniAppPaymentStatusResponse(results=[]) + + payment_service = PaymentService() + results: List[MiniAppPaymentStatusResult] = [] + + for entry in entries: + result = await _resolve_payment_status_entry( + payment_service=payment_service, + db=db, + user=user, + query=entry, + ) + if result: + results.append(result) + + return MiniAppPaymentStatusResponse(results=results) + + +async def _resolve_payment_status_entry( + *, + payment_service: PaymentService, + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + method = (query.method or "").strip().lower() + if not method: + return MiniAppPaymentStatusResult( + method="", + status="unknown", + message="Payment method is required", + ) + + if method in {"yookassa", "yookassa_sbp"}: + return await _resolve_yookassa_payment_status( + db, + user, + query, + method=method, + ) + if method == "mulenpay": + return await _resolve_mulenpay_payment_status(payment_service, db, user, query) + if method == "wata": + return await _resolve_wata_payment_status(payment_service, db, user, query) + if method == "pal24": + return await _resolve_pal24_payment_status(payment_service, db, user, query) + if method == "cryptobot": + return await _resolve_cryptobot_payment_status(db, user, query) + if method == "stars": + return await _resolve_stars_payment_status(db, user, query) + if method == "tribute": + return await _resolve_tribute_payment_status(db, user, query) + + return MiniAppPaymentStatusResult( + method=method, + status="unknown", + message="Unsupported payment method", + ) + + +async def _resolve_yookassa_payment_status( + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, + *, + method: str = "yookassa", +) -> MiniAppPaymentStatusResult: + from app.database.crud.yookassa import ( + get_yookassa_payment_by_id, + get_yookassa_payment_by_local_id, + ) + + payment = None + if query.local_payment_id: + payment = await get_yookassa_payment_by_local_id(db, query.local_payment_id) + if not payment and query.payment_id: + payment = await get_yookassa_payment_by_id(db, query.payment_id) + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResult( + method=method, + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Payment not found", + extra={ + "local_payment_id": query.local_payment_id, + "payment_id": query.payment_id, + "invoice_id": query.payment_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + succeeded = bool(payment.is_paid and (payment.status or "").lower() == "succeeded") + status = _classify_status(payment.status, succeeded) + completed_at = payment.captured_at or payment.updated_at or payment.created_at + + return MiniAppPaymentStatusResult( + method=method, + status=status, + is_paid=status == "paid", + amount_kopeks=payment.amount_kopeks, + currency=payment.currency, + completed_at=completed_at, + transaction_id=payment.transaction_id, + external_id=payment.yookassa_payment_id, + extra={ + "status": payment.status, + "is_paid": payment.is_paid, + "local_payment_id": payment.id, + "payment_id": payment.yookassa_payment_id, + "invoice_id": payment.yookassa_payment_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + +async def _resolve_mulenpay_payment_status( + payment_service: PaymentService, + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + if not query.local_payment_id: + return MiniAppPaymentStatusResult( + method="mulenpay", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Missing payment identifier", + extra={ + "local_payment_id": query.local_payment_id, + "invoice_id": query.invoice_id, + "payment_id": query.payment_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_info = await payment_service.get_mulenpay_payment_status(db, query.local_payment_id) + payment = status_info.get("payment") if status_info else None + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResult( + method="mulenpay", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Payment not found", + extra={ + "local_payment_id": query.local_payment_id, + "invoice_id": query.invoice_id, + "payment_id": query.payment_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_raw = status_info.get("status") or payment.status + is_paid = bool(payment.is_paid) + status = _classify_status(status_raw, is_paid) + completed_at = payment.paid_at or payment.updated_at or payment.created_at + message = None + if status == "failed": + remote_status = status_info.get("remote_status_code") or status_raw + if remote_status: + message = f"Status: {remote_status}" + + return MiniAppPaymentStatusResult( + method="mulenpay", + status=status, + is_paid=status == "paid", + amount_kopeks=payment.amount_kopeks, + currency=payment.currency, + completed_at=completed_at, + transaction_id=payment.transaction_id, + external_id=str(payment.mulen_payment_id or payment.uuid), + message=message, + extra={ + "status": payment.status, + "remote_status": status_info.get("remote_status_code"), + "local_payment_id": payment.id, + "payment_id": payment.mulen_payment_id, + "uuid": str(payment.uuid), + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + +async def _resolve_wata_payment_status( + payment_service: PaymentService, + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + local_id = query.local_payment_id + payment_link_id = query.payment_link_id or query.payment_id or query.invoice_id + fallback_payment = None + + if not local_id and payment_link_id: + fallback_payment = await get_wata_payment_by_link_id(db, payment_link_id) + if fallback_payment: + local_id = fallback_payment.id + + if not local_id: + return MiniAppPaymentStatusResult( + method="wata", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Missing payment identifier", + extra={ + "local_payment_id": query.local_payment_id, + "payment_link_id": payment_link_id, + "payment_id": query.payment_id, + "invoice_id": query.invoice_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_info = await payment_service.get_wata_payment_status(db, local_id) + payment = (status_info or {}).get("payment") or fallback_payment + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResult( + method="wata", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Payment not found", + extra={ + "local_payment_id": local_id, + "payment_link_id": (payment_link_id or getattr(payment, "payment_link_id", None)), + "payment_id": query.payment_id, + "invoice_id": query.invoice_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + remote_link = (status_info or {}).get("remote_link") if status_info else None + transaction_payload = (status_info or {}).get("transaction") if status_info else None + status_raw = (status_info or {}).get("status") or getattr(payment, "status", None) + is_paid_flag = bool((status_info or {}).get("is_paid") or getattr(payment, "is_paid", False)) + status_value = _classify_status(status_raw, is_paid_flag) + completed_at = ( + getattr(payment, "paid_at", None) + or getattr(payment, "updated_at", None) + or getattr(payment, "created_at", None) + ) + + message = None + if status_value == "failed": + message = ( + (transaction_payload or {}).get("errorDescription") + or (transaction_payload or {}).get("errorCode") + or (remote_link or {}).get("status") + ) + + extra: Dict[str, Any] = { + "local_payment_id": payment.id, + "payment_link_id": payment.payment_link_id, + "payment_id": payment.payment_link_id, + "status": status_raw, + "is_paid": getattr(payment, "is_paid", False), + "order_id": getattr(payment, "order_id", None), + "payload": query.payload, + "started_at": query.started_at, + } + if remote_link: + extra["remote_link"] = remote_link + if transaction_payload: + extra["transaction"] = transaction_payload + + return MiniAppPaymentStatusResult( + method="wata", + status=status_value, + is_paid=status_value == "paid", + amount_kopeks=payment.amount_kopeks, + currency=payment.currency, + completed_at=completed_at, + transaction_id=payment.transaction_id, + external_id=payment.payment_link_id, + message=message, + extra=extra, + ) + + +async def _resolve_pal24_payment_status( + payment_service: PaymentService, + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + from app.database.crud.pal24 import get_pal24_payment_by_bill_id + + local_id = query.local_payment_id + if not local_id and query.invoice_id: + payment_by_bill = await get_pal24_payment_by_bill_id(db, query.invoice_id) + if payment_by_bill and payment_by_bill.user_id == user.id: + local_id = payment_by_bill.id + + if not local_id: + return MiniAppPaymentStatusResult( + method="pal24", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Missing payment identifier", + extra={ + "local_payment_id": query.local_payment_id, + "bill_id": query.invoice_id, + "order_id": None, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_info = await payment_service.get_pal24_payment_status(db, local_id) + payment = status_info.get("payment") if status_info else None + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResult( + method="pal24", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Payment not found", + extra={ + "local_payment_id": local_id, + "bill_id": query.invoice_id, + "order_id": None, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_raw = status_info.get("status") or payment.status + is_paid = bool(payment.is_paid) + status = _classify_status(status_raw, is_paid) + completed_at = payment.paid_at or payment.updated_at or payment.created_at + message = None + if status == "failed": + remote_status = status_info.get("remote_status") or status_raw + if remote_status: + message = f"Status: {remote_status}" + + return MiniAppPaymentStatusResult( + method="pal24", + status=status, + is_paid=status == "paid", + amount_kopeks=payment.amount_kopeks, + currency=payment.currency, + completed_at=completed_at, + transaction_id=payment.transaction_id, + external_id=payment.bill_id, + message=message, + extra={ + "status": payment.status, + "remote_status": status_info.get("remote_status"), + "local_payment_id": payment.id, + "bill_id": payment.bill_id, + "order_id": payment.order_id, + "payment_method": getattr(payment, "payment_method", None), + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + +async def _resolve_cryptobot_payment_status( + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + from app.database.crud.cryptobot import ( + get_cryptobot_payment_by_id, + get_cryptobot_payment_by_invoice_id, + ) + + payment = None + if query.local_payment_id: + payment = await get_cryptobot_payment_by_id(db, query.local_payment_id) + if not payment and query.invoice_id: + payment = await get_cryptobot_payment_by_invoice_id(db, query.invoice_id) + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResult( + method="cryptobot", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Payment not found", + extra={ + "local_payment_id": query.local_payment_id, + "invoice_id": query.invoice_id, + "payment_id": query.payment_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_raw = payment.status + is_paid = (status_raw or "").lower() == "paid" + status = _classify_status(status_raw, is_paid) + completed_at = payment.paid_at or payment.updated_at or payment.created_at + + amount_kopeks = None + try: + amount_kopeks = int(Decimal(payment.amount) * Decimal(100)) + except (InvalidOperation, TypeError): + amount_kopeks = None + + return MiniAppPaymentStatusResult( + method="cryptobot", + status=status, + is_paid=status == "paid", + amount_kopeks=amount_kopeks, + currency=payment.asset, + completed_at=completed_at, + transaction_id=payment.transaction_id, + external_id=payment.invoice_id, + extra={ + "status": payment.status, + "asset": payment.asset, + "local_payment_id": payment.id, + "invoice_id": payment.invoice_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + +async def _resolve_stars_payment_status( + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + started_at = _parse_client_timestamp(query.started_at) + transaction = await _find_recent_deposit( + db, + user_id=user.id, + payment_method=PaymentMethod.TELEGRAM_STARS, + amount_kopeks=query.amount_kopeks, + started_at=started_at, + ) + + if not transaction: + return MiniAppPaymentStatusResult( + method="stars", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Waiting for confirmation", + extra={ + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + return MiniAppPaymentStatusResult( + method="stars", + status="paid", + is_paid=True, + amount_kopeks=transaction.amount_kopeks, + currency="RUB", + completed_at=transaction.completed_at or transaction.created_at, + transaction_id=transaction.id, + external_id=transaction.external_id, + extra={ + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + +async def _resolve_tribute_payment_status( + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + started_at = _parse_client_timestamp(query.started_at) + transaction = await _find_recent_deposit( + db, + user_id=user.id, + payment_method=PaymentMethod.TRIBUTE, + amount_kopeks=query.amount_kopeks, + started_at=started_at, + ) + + if not transaction: + return MiniAppPaymentStatusResult( + method="tribute", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Waiting for confirmation", + extra={ + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + return MiniAppPaymentStatusResult( + method="tribute", + status="paid", + is_paid=True, + amount_kopeks=transaction.amount_kopeks, + currency="RUB", + completed_at=transaction.completed_at or transaction.created_at, + transaction_id=transaction.id, + external_id=transaction.external_id, + extra={ + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + +_TEMPLATE_ID_PATTERN = re.compile(r"promo_template_(?P\d+)$") +_OFFER_TYPE_ICONS = { + "extend_discount": "πŸ’Ž", + "purchase_discount": "🎯", + "test_access": "πŸ§ͺ", +} +_EFFECT_TYPE_ICONS = { + "percent_discount": "🎁", + "test_access": "πŸ§ͺ", + "balance_bonus": "πŸ’°", +} +_DEFAULT_OFFER_ICON = "πŸŽ‰" + +ActiveOfferContext = Tuple[Any, Optional[int], Optional[datetime]] + + +def _extract_template_id(notification_type: Optional[str]) -> Optional[int]: + if not notification_type: + return None + + match = _TEMPLATE_ID_PATTERN.match(notification_type) + if not match: + return None + + try: + return int(match.group("template_id")) + except (TypeError, ValueError): + return None + + +def _extract_offer_extra(offer: Any) -> Dict[str, Any]: + extra = getattr(offer, "extra_data", None) + return extra if isinstance(extra, dict) else {} + + +def _extract_offer_type(offer: Any, template: Optional[PromoOfferTemplate]) -> Optional[str]: + extra = _extract_offer_extra(offer) + offer_type = extra.get("offer_type") if isinstance(extra.get("offer_type"), str) else None + if offer_type: + return offer_type + template_type = getattr(template, "offer_type", None) + return template_type if isinstance(template_type, str) else None + + +def _normalize_effect_type(effect_type: Optional[str]) -> str: + normalized = (effect_type or "percent_discount").strip().lower() + if normalized == "balance_bonus": + return "percent_discount" + return normalized or "percent_discount" + + +def _determine_offer_icon(offer_type: Optional[str], effect_type: str) -> str: + if offer_type and offer_type in _OFFER_TYPE_ICONS: + return _OFFER_TYPE_ICONS[offer_type] + if effect_type in _EFFECT_TYPE_ICONS: + return _EFFECT_TYPE_ICONS[effect_type] + return _DEFAULT_OFFER_ICON + + +def _extract_offer_test_squad_uuids(offer: Any) -> List[str]: + extra = _extract_offer_extra(offer) + raw = extra.get("test_squad_uuids") or extra.get("squads") or [] + + if isinstance(raw, str): + raw = [raw] + + uuids: List[str] = [] + try: + for item in raw: + if not item: + continue + uuids.append(str(item)) + except TypeError: + return [] + + return uuids + + +def _format_offer_message( + template: Optional[PromoOfferTemplate], + offer: Any, + *, + server_name: Optional[str] = None, +) -> Optional[str]: + message_template: Optional[str] = None + + if template and isinstance(template.message_text, str): + message_template = template.message_text + else: + extra = _extract_offer_extra(offer) + raw_message = extra.get("message_text") or extra.get("text") + if isinstance(raw_message, str): + message_template = raw_message + + if not message_template: + return None + + extra = _extract_offer_extra(offer) + discount_percent = getattr(offer, "discount_percent", None) + try: + discount_percent = int(discount_percent) + except (TypeError, ValueError): + discount_percent = None + + replacements: Dict[str, Any] = {} + if discount_percent is not None: + replacements.setdefault("discount_percent", discount_percent) + + for key in ("valid_hours", "active_discount_hours", "test_duration_hours"): + value = extra.get(key) + if value is None and template is not None: + template_value = getattr(template, key, None) + else: + template_value = None + replacements.setdefault(key, value if value is not None else template_value) + + if replacements.get("active_discount_hours") is None and template: + replacements["active_discount_hours"] = getattr(template, "valid_hours", None) + + if replacements.get("test_duration_hours") is None and template: + replacements["test_duration_hours"] = getattr(template, "test_duration_hours", None) + + if server_name: + replacements.setdefault("server_name", server_name) + + for key, value in extra.items(): + if ( + isinstance(key, str) + and key not in replacements + and isinstance(value, (str, int, float)) + ): + replacements[key] = value + + try: + return message_template.format(**replacements) + except Exception: # pragma: no cover - fallback for malformed templates + return message_template + + +def _extract_offer_duration_hours( + offer: Any, + template: Optional[PromoOfferTemplate], + effect_type: str, +) -> Optional[int]: + extra = _extract_offer_extra(offer) + if effect_type == "test_access": + source = extra.get("test_duration_hours") + if source is None and template is not None: + source = getattr(template, "test_duration_hours", None) + else: + source = extra.get("active_discount_hours") + if source is None and template is not None: + source = getattr(template, "active_discount_hours", None) + + try: + if source is None: + return None + hours = int(float(source)) + return hours if hours > 0 else None + except (TypeError, ValueError): + return None + + +def _format_bonus_label(amount_kopeks: int) -> Optional[str]: + if amount_kopeks <= 0: + return None + try: + return settings.format_price(amount_kopeks) + except Exception: # pragma: no cover - defensive + return f"{amount_kopeks / 100:.2f}" + + +async def _find_active_test_access_offers( + db: AsyncSession, + subscription: Optional[Subscription], +) -> List[ActiveOfferContext]: + if not subscription or not getattr(subscription, "id", None): + return [] + + now = datetime.utcnow() + result = await db.execute( + select(SubscriptionTemporaryAccess) + .options(selectinload(SubscriptionTemporaryAccess.offer)) + .where( + SubscriptionTemporaryAccess.subscription_id == subscription.id, + SubscriptionTemporaryAccess.is_active == True, # noqa: E712 + SubscriptionTemporaryAccess.expires_at > now, + ) + .order_by(SubscriptionTemporaryAccess.expires_at.desc()) + ) + + entries = list(result.scalars().all()) + if not entries: + return [] + + offer_map: Dict[int, Tuple[Any, Optional[datetime]]] = {} + for entry in entries: + offer = getattr(entry, "offer", None) + if not offer: + continue + + effect_type = _normalize_effect_type(getattr(offer, "effect_type", None)) + if effect_type != "test_access": + continue + + expires_at = getattr(entry, "expires_at", None) + if not expires_at or expires_at <= now: + continue + + offer_id = getattr(offer, "id", None) + if not isinstance(offer_id, int): + continue + + current = offer_map.get(offer_id) + if current is None: + offer_map[offer_id] = (offer, expires_at) + else: + _, current_expiry = current + if current_expiry is None or (expires_at and expires_at > current_expiry): + offer_map[offer_id] = (offer, expires_at) + + contexts: List[ActiveOfferContext] = [] + for offer_id, (offer, expires_at) in offer_map.items(): + contexts.append((offer, None, expires_at)) + + contexts.sort(key=lambda item: item[2] or now, reverse=True) + return contexts + + +async def _build_promo_offer_models( + db: AsyncSession, + available_offers: List[Any], + active_offers: Optional[List[ActiveOfferContext]], + *, + user: User, +) -> List[MiniAppPromoOffer]: + promo_offers: List[MiniAppPromoOffer] = [] + template_cache: Dict[int, Optional[PromoOfferTemplate]] = {} + + candidates: List[Any] = [offer for offer in available_offers if offer] + active_offer_contexts: List[ActiveOfferContext] = [] + if active_offers: + for offer, discount_override, expires_override in active_offers: + if not offer: + continue + active_offer_contexts.append((offer, discount_override, expires_override)) + candidates.append(offer) + + squad_map: Dict[str, MiniAppConnectedServer] = {} + if candidates: + all_uuids: List[str] = [] + for offer in candidates: + all_uuids.extend(_extract_offer_test_squad_uuids(offer)) + if all_uuids: + unique = list(dict.fromkeys(all_uuids)) + resolved = await _resolve_connected_servers(db, unique) + squad_map = {server.uuid: server for server in resolved} + + async def get_template(template_id: Optional[int]) -> Optional[PromoOfferTemplate]: + if not template_id: + return None + if template_id not in template_cache: + template_cache[template_id] = await get_promo_offer_template_by_id(db, template_id) + return template_cache[template_id] + + def build_test_squads(offer: Any) -> List[MiniAppConnectedServer]: + test_squads: List[MiniAppConnectedServer] = [] + for uuid in _extract_offer_test_squad_uuids(offer): + resolved = squad_map.get(uuid) + if resolved: + test_squads.append( + MiniAppConnectedServer(uuid=resolved.uuid, name=resolved.name) + ) + else: + test_squads.append(MiniAppConnectedServer(uuid=uuid, name=uuid)) + return test_squads + + def resolve_title( + offer: Any, + template: Optional[PromoOfferTemplate], + offer_type: Optional[str], + ) -> Optional[str]: + extra = _extract_offer_extra(offer) + if isinstance(extra.get("title"), str) and extra["title"].strip(): + return extra["title"].strip() + if template and template.name: + return template.name + if offer_type: + return offer_type.replace("_", " ").title() + return None + + for offer in available_offers: + template_id = _extract_template_id(getattr(offer, "notification_type", None)) + template = await get_template(template_id) + effect_type = _normalize_effect_type(getattr(offer, "effect_type", None)) + offer_type = _extract_offer_type(offer, template) + test_squads = build_test_squads(offer) + server_name = test_squads[0].name if test_squads else None + message_text = _format_offer_message(template, offer, server_name=server_name) + bonus_label = _format_bonus_label(int(getattr(offer, "bonus_amount_kopeks", 0) or 0)) + discount_percent = getattr(offer, "discount_percent", 0) + try: + discount_percent = int(discount_percent) + except (TypeError, ValueError): + discount_percent = 0 + + extra = _extract_offer_extra(offer) + button_text = None + if isinstance(extra.get("button_text"), str) and extra["button_text"].strip(): + button_text = extra["button_text"].strip() + elif template and isinstance(template.button_text, str): + button_text = template.button_text + + promo_offers.append( + MiniAppPromoOffer( + id=int(getattr(offer, "id", 0) or 0), + status="pending", + notification_type=getattr(offer, "notification_type", None), + offer_type=offer_type, + effect_type=effect_type, + discount_percent=max(0, discount_percent), + bonus_amount_kopeks=int(getattr(offer, "bonus_amount_kopeks", 0) or 0), + bonus_amount_label=bonus_label, + expires_at=getattr(offer, "expires_at", None), + claimed_at=getattr(offer, "claimed_at", None), + is_active=bool(getattr(offer, "is_active", False)), + template_id=template_id, + template_name=getattr(template, "name", None), + button_text=button_text, + title=resolve_title(offer, template, offer_type), + message_text=message_text, + icon=_determine_offer_icon(offer_type, effect_type), + test_squads=test_squads, + ) + ) + + if active_offer_contexts: + seen_active_ids: set[int] = set() + for active_offer_record, discount_override, expires_override in reversed(active_offer_contexts): + offer_id = int(getattr(active_offer_record, "id", 0) or 0) + if offer_id and offer_id in seen_active_ids: + continue + if offer_id: + seen_active_ids.add(offer_id) + + template_id = _extract_template_id(getattr(active_offer_record, "notification_type", None)) + template = await get_template(template_id) + effect_type = _normalize_effect_type(getattr(active_offer_record, "effect_type", None)) + offer_type = _extract_offer_type(active_offer_record, template) + show_active = False + discount_value = discount_override if discount_override is not None else 0 + if discount_value and discount_value > 0: + show_active = True + elif effect_type == "test_access": + show_active = True + if not show_active: + continue + + test_squads = build_test_squads(active_offer_record) + server_name = test_squads[0].name if test_squads else None + message_text = _format_offer_message( + template, + active_offer_record, + server_name=server_name, + ) + bonus_label = _format_bonus_label( + int(getattr(active_offer_record, "bonus_amount_kopeks", 0) or 0) + ) + + started_at = getattr(active_offer_record, "claimed_at", None) + expires_at = expires_override or getattr(active_offer_record, "expires_at", None) + duration_seconds: Optional[int] = None + duration_hours = _extract_offer_duration_hours(active_offer_record, template, effect_type) + if expires_at is None and duration_hours and started_at: + expires_at = started_at + timedelta(hours=duration_hours) + if expires_at and started_at: + try: + duration_seconds = int((expires_at - started_at).total_seconds()) + except Exception: # pragma: no cover - defensive + duration_seconds = None + + if (discount_value is None or discount_value <= 0) and effect_type != "test_access": + try: + discount_value = int(getattr(active_offer_record, "discount_percent", 0) or 0) + except (TypeError, ValueError): + discount_value = 0 + if discount_value is None: + discount_value = 0 + + extra = _extract_offer_extra(active_offer_record) + button_text = None + if isinstance(extra.get("button_text"), str) and extra["button_text"].strip(): + button_text = extra["button_text"].strip() + elif template and isinstance(template.button_text, str): + button_text = template.button_text + + promo_offers.insert( + 0, + MiniAppPromoOffer( + id=offer_id, + status="active", + notification_type=getattr(active_offer_record, "notification_type", None), + offer_type=offer_type, + effect_type=effect_type, + discount_percent=max(0, discount_value or 0), + bonus_amount_kopeks=int(getattr(active_offer_record, "bonus_amount_kopeks", 0) or 0), + bonus_amount_label=bonus_label, + expires_at=getattr(active_offer_record, "expires_at", None), + claimed_at=started_at, + is_active=False, + template_id=template_id, + template_name=getattr(template, "name", None), + button_text=button_text, + title=resolve_title(active_offer_record, template, offer_type), + message_text=message_text, + icon=_determine_offer_icon(offer_type, effect_type), + test_squads=test_squads, + active_discount_expires_at=expires_at, + active_discount_started_at=started_at, + active_discount_duration_seconds=duration_seconds, + ), + ) + + return promo_offers + + +def _bytes_to_gb(bytes_value: Optional[int]) -> float: + if not bytes_value: + return 0.0 + return round(bytes_value / (1024 ** 3), 2) + + +def _status_label(status: str) -> str: + mapping = { + "active": "Active", + "trial": "Trial", + "expired": "Expired", + "disabled": "Disabled", + } + return mapping.get(status, status.title()) + + +def _parse_datetime_string(value: Optional[str]) -> Optional[str]: + if not value: + return None + + try: + cleaned = value.strip() + if cleaned.endswith("Z"): + cleaned = f"{cleaned[:-1]}+00:00" + # Normalize duplicated timezone suffixes like +00:00+00:00 + if "+00:00+00:00" in cleaned: + cleaned = cleaned.replace("+00:00+00:00", "+00:00") + + datetime.fromisoformat(cleaned) + return cleaned + except Exception: # pragma: no cover - defensive + return value + + +async def _resolve_connected_servers( + db: AsyncSession, + squad_uuids: List[str], +) -> List[MiniAppConnectedServer]: + if not squad_uuids: + return [] + + resolved: Dict[str, str] = {} + missing: List[str] = [] + + for squad_uuid in squad_uuids: + if squad_uuid in resolved: + continue + server = await get_server_squad_by_uuid(db, squad_uuid) + if server and server.display_name: + resolved[squad_uuid] = server.display_name + else: + missing.append(squad_uuid) + + if missing: + try: + service = RemnaWaveService() + if service.is_configured: + squads = await service.get_all_squads() + for squad in squads: + uuid = squad.get("uuid") + name = squad.get("name") + if uuid in missing and name: + resolved[uuid] = name + except RemnaWaveConfigurationError: + logger.debug("RemnaWave is not configured; skipping server name enrichment") + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to resolve server names from RemnaWave: %s", error) + + connected_servers: List[MiniAppConnectedServer] = [] + for squad_uuid in squad_uuids: + name = resolved.get(squad_uuid, squad_uuid) + connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name)) + + return connected_servers + + +async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: + remnawave_uuid = getattr(user, "remnawave_uuid", None) + if not remnawave_uuid: + return 0, [] + + try: + service = RemnaWaveService() + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to initialise RemnaWave service: %s", error) + return 0, [] + + if not service.is_configured: + return 0, [] + + try: + async with service.get_api_client() as api: + response = await api.get_user_devices(remnawave_uuid) + except RemnaWaveConfigurationError: + logger.debug("RemnaWave configuration missing while loading devices") + return 0, [] + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to load devices from RemnaWave: %s", error) + return 0, [] + + total_devices = int(response.get("total") or 0) + devices_payload = response.get("devices") or [] + + devices: List[MiniAppDevice] = [] + for device in devices_payload: + hwid = device.get("hwid") or device.get("deviceId") or device.get("id") + platform = device.get("platform") or device.get("platformType") + model = device.get("deviceModel") or device.get("model") or device.get("name") + app_version = device.get("appVersion") or device.get("version") + last_seen_raw = ( + device.get("updatedAt") + or device.get("lastSeen") + or device.get("lastActiveAt") + or device.get("createdAt") + ) + last_ip = device.get("ip") or device.get("ipAddress") + + devices.append( + MiniAppDevice( + hwid=hwid, + platform=platform, + device_model=model, + app_version=app_version, + last_seen=_parse_datetime_string(last_seen_raw), + last_ip=last_ip, + ) + ) + + if total_devices == 0: + total_devices = len(devices) + + return total_devices, devices + + +def _resolve_display_name(user_data: Dict[str, Any]) -> str: + username = user_data.get("username") + if username: + return username + + first = user_data.get("first_name") + last = user_data.get("last_name") + parts = [part for part in [first, last] if part] + if parts: + return " ".join(parts) + + telegram_id = user_data.get("telegram_id") + return f"User {telegram_id}" if telegram_id else "User" + + +def _is_remnawave_configured() -> bool: + params = settings.get_remnawave_auth_params() + return bool(params.get("base_url") and params.get("api_key")) + + +def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction: + return MiniAppTransaction( + id=transaction.id, + type=transaction.type, + amount_kopeks=transaction.amount_kopeks, + amount_rubles=round(transaction.amount_kopeks / 100, 2), + description=transaction.description, + payment_method=transaction.payment_method, + external_id=transaction.external_id, + is_completed=transaction.is_completed, + created_at=transaction.created_at, + completed_at=transaction.completed_at, + ) + + +async def _load_subscription_links( + subscription: Subscription, +) -> Dict[str, Any]: + if not subscription.remnawave_short_uuid or not _is_remnawave_configured(): + return {} + + try: + service = SubscriptionService() + info = await service.get_subscription_info(subscription.remnawave_short_uuid) + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to load subscription info from RemnaWave: %s", error) + return {} + + if not info: + return {} + + payload: Dict[str, Any] = { + "links": list(info.links or []), + "ss_conf_links": dict(info.ss_conf_links or {}), + "subscription_url": info.subscription_url, + "happ": info.happ, + "happ_link": getattr(info, "happ_link", None), + "happ_crypto_link": getattr(info, "happ_crypto_link", None), + } + + return payload + + +async def _build_referral_info( + db: AsyncSession, + user: User, +) -> Optional[MiniAppReferralInfo]: + referral_code = getattr(user, "referral_code", None) + referral_settings = settings.get_referral_settings() or {} + + bot_username = settings.get_bot_username() + referral_link = None + if referral_code and bot_username: + referral_link = f"https://t.me/{bot_username}?start={referral_code}" + + minimum_topup_kopeks = int(referral_settings.get("minimum_topup_kopeks") or 0) + first_topup_bonus_kopeks = int(referral_settings.get("first_topup_bonus_kopeks") or 0) + inviter_bonus_kopeks = int(referral_settings.get("inviter_bonus_kopeks") or 0) + commission_percent = float(referral_settings.get("commission_percent") or 0) + + referred_user_reward_kopeks = settings.get_referred_user_reward_kopeks() + for key in ("referred_user_reward_kopeks", "referred_user_reward"): + candidate = referral_settings.get(key) + if candidate is None: + continue + try: + value = int(candidate) + except (TypeError, ValueError): + continue + if value <= 0: + referred_user_reward_kopeks = 0 + break + if key == "referred_user_reward" and value < 1000: + value *= 100 + referred_user_reward_kopeks = value + break + + terms = MiniAppReferralTerms( + minimum_topup_kopeks=minimum_topup_kopeks, + minimum_topup_label=settings.format_price(minimum_topup_kopeks), + first_topup_bonus_kopeks=first_topup_bonus_kopeks, + first_topup_bonus_label=settings.format_price(first_topup_bonus_kopeks), + inviter_bonus_kopeks=inviter_bonus_kopeks, + inviter_bonus_label=settings.format_price(inviter_bonus_kopeks), + commission_percent=commission_percent, + referred_user_reward_kopeks=referred_user_reward_kopeks, + referred_user_reward_label=settings.format_price(referred_user_reward_kopeks), + ) + + summary = await get_user_referral_summary(db, user.id) + stats: Optional[MiniAppReferralStats] = None + recent_earnings: List[MiniAppReferralRecentEarning] = [] + + if summary: + total_earned_kopeks = int(summary.get("total_earned_kopeks") or 0) + month_earned_kopeks = int(summary.get("month_earned_kopeks") or 0) + + stats = MiniAppReferralStats( + invited_count=int(summary.get("invited_count") or 0), + paid_referrals_count=int(summary.get("paid_referrals_count") or 0), + active_referrals_count=int(summary.get("active_referrals_count") or 0), + total_earned_kopeks=total_earned_kopeks, + total_earned_label=settings.format_price(total_earned_kopeks), + month_earned_kopeks=month_earned_kopeks, + month_earned_label=settings.format_price(month_earned_kopeks), + conversion_rate=float(summary.get("conversion_rate") or 0.0), + ) + + for earning in summary.get("recent_earnings", []) or []: + amount = int(earning.get("amount_kopeks") or 0) + recent_earnings.append( + MiniAppReferralRecentEarning( + amount_kopeks=amount, + amount_label=settings.format_price(amount), + reason=earning.get("reason"), + referral_name=earning.get("referral_name"), + created_at=earning.get("created_at"), + ) + ) + + detailed = await get_detailed_referral_list(db, user.id, limit=50, offset=0) + referral_items: List[MiniAppReferralItem] = [] + if detailed: + for item in detailed.get("referrals", []) or []: + total_earned = int(item.get("total_earned_kopeks") or 0) + balance = int(item.get("balance_kopeks") or 0) + referral_items.append( + MiniAppReferralItem( + id=int(item.get("id") or 0), + telegram_id=item.get("telegram_id"), + full_name=item.get("full_name"), + username=item.get("username"), + created_at=item.get("created_at"), + last_activity=item.get("last_activity"), + has_made_first_topup=bool(item.get("has_made_first_topup")), + balance_kopeks=balance, + balance_label=settings.format_price(balance), + total_earned_kopeks=total_earned, + total_earned_label=settings.format_price(total_earned), + topups_count=int(item.get("topups_count") or 0), + days_since_registration=item.get("days_since_registration"), + days_since_activity=item.get("days_since_activity"), + status=item.get("status"), + ) + ) + + referral_list = MiniAppReferralList( + total_count=int(detailed.get("total_count") or 0) if detailed else 0, + has_next=bool(detailed.get("has_next")) if detailed else False, + has_prev=bool(detailed.get("has_prev")) if detailed else False, + current_page=int(detailed.get("current_page") or 1) if detailed else 1, + total_pages=int(detailed.get("total_pages") or 1) if detailed else 1, + items=referral_items, + ) + + if ( + not referral_code + and not referral_link + and not referral_items + and not recent_earnings + and (not stats or (stats.invited_count == 0 and stats.total_earned_kopeks == 0)) + ): + return None + + return MiniAppReferralInfo( + referral_code=referral_code, + referral_link=referral_link, + terms=terms, + stats=stats, + recent_earnings=recent_earnings, + referrals=referral_list, + ) + + +def _is_trial_available_for_user(user: User) -> bool: + if settings.TRIAL_DURATION_DAYS <= 0: + return False + + if getattr(user, "has_had_paid_subscription", False): + return False + + subscription = getattr(user, "subscription", None) + if subscription is not None: + return False + + return True + + @router.post("/subscription", response_model=MiniAppSubscriptionResponse) async def get_subscription_details( payload: MiniAppSubscriptionRequest, @@ -416,7 +2516,7 @@ async def get_subscription_details( threshold_label=settings.format_price(threshold), is_reached=total_spent_kopeks >= threshold, is_current=bool(promo_group and promo_group.id == group.id), - **extract_promo_discounts(group), + **_extract_promo_discounts(group), ) ) @@ -453,10 +2553,10 @@ async def get_subscription_details( if subscription: active_offer_contexts.extend( - await find_active_test_access_offers(db, subscription) + await _find_active_test_access_offers(db, subscription) ) - promo_offers = await build_promo_offer_models( + promo_offers = await _build_promo_offer_models( db, available_promo_offers, active_offer_contexts, @@ -611,7 +2711,7 @@ async def get_subscription_details( ) happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link) connected_squads = list(subscription.connected_squads or []) - connected_servers = await resolve_connected_servers(db, connected_squads) + connected_servers = await _resolve_connected_servers(db, connected_squads) links = links_payload.get("links") or connected_squads ss_conf_links = links_payload.get("ss_conf_links") or {} remnawave_short_uuid = subscription.remnawave_short_uuid @@ -710,7 +2810,7 @@ async def get_subscription_details( MiniAppPromoGroup( id=promo_group.id, name=promo_group.name, - **extract_promo_discounts(promo_group), + **_extract_promo_discounts(promo_group), ) if promo_group else None @@ -960,7 +3060,1006 @@ async def activate_subscription_trial_endpoint( ) -@router.post("/subscription/renewal/options", +@router.post( + "/promo-codes/activate", + response_model=MiniAppPromoCodeActivationResponse, +) +async def activate_promo_code( + payload: MiniAppPromoCodeActivationRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppPromoCodeActivationResponse: + try: + webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) + except TelegramWebAppAuthError as error: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": str(error)}, + ) from error + + telegram_user = webapp_data.get("user") + if not isinstance(telegram_user, dict) or "id" not in telegram_user: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, + ) + + try: + telegram_id = int(telegram_user["id"]) + except (TypeError, ValueError): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, + ) from None + + user = await get_user_by_telegram_id(db, telegram_id) + if not user: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"code": "user_not_found", "message": "User not found"}, + ) + + code = (payload.code or "").strip().upper() + if not code: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid", "message": "Promo code must not be empty"}, + ) + + result = await promo_code_service.activate_promocode(db, user.id, code) + if result.get("success"): + promocode_data = result.get("promocode") or {} + + try: + balance_bonus = int(promocode_data.get("balance_bonus_kopeks") or 0) + except (TypeError, ValueError): + balance_bonus = 0 + + try: + subscription_days = int(promocode_data.get("subscription_days") or 0) + except (TypeError, ValueError): + subscription_days = 0 + + promo_payload = MiniAppPromoCode( + code=str(promocode_data.get("code") or code), + type=promocode_data.get("type"), + balance_bonus_kopeks=balance_bonus, + subscription_days=subscription_days, + max_uses=promocode_data.get("max_uses"), + current_uses=promocode_data.get("current_uses"), + valid_until=promocode_data.get("valid_until"), + ) + + return MiniAppPromoCodeActivationResponse( + success=True, + description=result.get("description"), + promocode=promo_payload, + ) + + error_code = str(result.get("error") or "generic") + status_map = { + "user_not_found": status.HTTP_404_NOT_FOUND, + "not_found": status.HTTP_404_NOT_FOUND, + "expired": status.HTTP_410_GONE, + "used": status.HTTP_409_CONFLICT, + "already_used_by_user": status.HTTP_409_CONFLICT, + "server_error": status.HTTP_500_INTERNAL_SERVER_ERROR, + } + message_map = { + "invalid": "Promo code must not be empty", + "not_found": "Promo code not found", + "expired": "Promo code expired", + "used": "Promo code already used", + "already_used_by_user": "Promo code already used by this user", + "user_not_found": "User not found", + "server_error": "Failed to activate promo code", + } + + http_status = status_map.get(error_code, status.HTTP_400_BAD_REQUEST) + message = message_map.get(error_code, "Unable to activate promo code") + + raise HTTPException( + http_status, + detail={"code": error_code, "message": message}, + ) + + +@router.post( + "/promo-offers/{offer_id}/claim", + response_model=MiniAppPromoOfferClaimResponse, +) +async def claim_promo_offer( + offer_id: int, + payload: MiniAppPromoOfferClaimRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppPromoOfferClaimResponse: + try: + webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) + except TelegramWebAppAuthError as error: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": str(error)}, + ) from error + + telegram_user = webapp_data.get("user") + if not isinstance(telegram_user, dict) or "id" not in telegram_user: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, + ) + + try: + telegram_id = int(telegram_user["id"]) + except (TypeError, ValueError): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, + ) from None + + user = await get_user_by_telegram_id(db, telegram_id) + if not user: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"code": "user_not_found", "message": "User not found"}, + ) + + offer = await get_offer_by_id(db, offer_id) + if not offer or offer.user_id != user.id: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"code": "offer_not_found", "message": "Offer not found"}, + ) + + now = datetime.utcnow() + if offer.claimed_at is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail={"code": "already_claimed", "message": "Offer already claimed"}, + ) + + if not offer.is_active or offer.expires_at <= now: + offer.is_active = False + await db.commit() + raise HTTPException( + status.HTTP_410_GONE, + detail={"code": "offer_expired", "message": "Offer expired"}, + ) + + effect_type = _normalize_effect_type(getattr(offer, "effect_type", None)) + + if effect_type == "test_access": + success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access( + db, + user, + offer, + ) + + if not success: + code = error_code or "claim_failed" + message_map = { + "subscription_missing": "Active subscription required", + "squads_missing": "No squads configured for test access", + "already_connected": "Servers already connected", + "remnawave_sync_failed": "Failed to apply servers", + } + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": code, "message": message_map.get(code, "Unable to activate offer")}, + ) + + await mark_offer_claimed( + db, + offer, + details={ + "context": "test_access_claim", + "new_squads": newly_added, + "expires_at": expires_at.isoformat() if expires_at else None, + }, + ) + + return MiniAppPromoOfferClaimResponse(success=True, code="test_access_claimed") + + discount_percent = int(getattr(offer, "discount_percent", 0) or 0) + if discount_percent <= 0: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_discount", "message": "Offer does not contain discount"}, + ) + + user.promo_offer_discount_percent = discount_percent + user.promo_offer_discount_source = offer.notification_type + user.updated_at = now + + extra_data = _extract_offer_extra(offer) + raw_duration = extra_data.get("active_discount_hours") + template_id = extra_data.get("template_id") + + if raw_duration in (None, "") and template_id: + try: + template = await get_promo_offer_template_by_id(db, int(template_id)) + except (TypeError, ValueError): + template = None + if template and template.active_discount_hours: + raw_duration = template.active_discount_hours + else: + template = None + + try: + duration_hours = int(raw_duration) if raw_duration is not None else None + except (TypeError, ValueError): + duration_hours = None + + if duration_hours and duration_hours > 0: + discount_expires_at = now + timedelta(hours=duration_hours) + else: + discount_expires_at = None + + user.promo_offer_discount_expires_at = discount_expires_at + + await mark_offer_claimed( + db, + offer, + details={ + "context": "discount_claim", + "discount_percent": discount_percent, + "discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None, + }, + ) + await db.refresh(user) + + return MiniAppPromoOfferClaimResponse(success=True, code="discount_claimed") + + +@router.post( + "/devices/remove", + response_model=MiniAppDeviceRemovalResponse, +) +async def remove_connected_device( + payload: MiniAppDeviceRemovalRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppDeviceRemovalResponse: + try: + webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) + except TelegramWebAppAuthError as error: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": str(error)}, + ) from error + + telegram_user = webapp_data.get("user") + if not isinstance(telegram_user, dict) or "id" not in telegram_user: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, + ) + + try: + telegram_id = int(telegram_user["id"]) + except (TypeError, ValueError): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, + ) from None + + user = await get_user_by_telegram_id(db, telegram_id) + if not user: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"code": "user_not_found", "message": "User not found"}, + ) + + remnawave_uuid = getattr(user, "remnawave_uuid", None) + if not remnawave_uuid: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail={"code": "remnawave_unavailable", "message": "RemnaWave user is not linked"}, + ) + + hwid = (payload.hwid or "").strip() + if not hwid: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_hwid", "message": "Device identifier is required"}, + ) + + service = RemnaWaveService() + if not service.is_configured: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"code": "service_unavailable", "message": "Device management is temporarily unavailable"}, + ) + + try: + async with service.get_api_client() as api: + success = await api.remove_device(remnawave_uuid, hwid) + except RemnaWaveConfigurationError as error: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"code": "service_unavailable", "message": str(error)}, + ) from error + except Exception as error: # pragma: no cover - defensive + logger.warning( + "Failed to remove device %s for user %s: %s", + hwid, + telegram_id, + error, + ) + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + detail={"code": "remnawave_error", "message": "Failed to remove device"}, + ) from error + + if not success: + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + detail={"code": "remnawave_error", "message": "Failed to remove device"}, + ) + + return MiniAppDeviceRemovalResponse(success=True) + + +def _safe_int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _normalize_period_discounts( + raw: Optional[Dict[Any, Any]] +) -> Dict[int, int]: + if not isinstance(raw, dict): + return {} + + normalized: Dict[int, int] = {} + for key, value in raw.items(): + try: + period = int(key) + normalized[period] = int(value) + except (TypeError, ValueError): + continue + + return normalized + + +def _extract_promo_discounts(group: Optional[PromoGroup]) -> Dict[str, Any]: + if not group: + return { + "server_discount_percent": 0, + "traffic_discount_percent": 0, + "device_discount_percent": 0, + "period_discounts": {}, + "apply_discounts_to_addons": True, + } + + return { + "server_discount_percent": max(0, _safe_int(getattr(group, "server_discount_percent", 0))), + "traffic_discount_percent": max(0, _safe_int(getattr(group, "traffic_discount_percent", 0))), + "device_discount_percent": max(0, _safe_int(getattr(group, "device_discount_percent", 0))), + "period_discounts": _normalize_period_discounts(getattr(group, "period_discounts", None)), + "apply_discounts_to_addons": bool( + getattr(group, "apply_discounts_to_addons", True) + ), + } + + +def _normalize_language_code(user: Optional[User]) -> str: + language = getattr(user, "language", None) or settings.DEFAULT_LANGUAGE or "ru" + return language.split("-")[0].lower() + + +def _build_renewal_status_message(user: Optional[User]) -> str: + language_code = _normalize_language_code(user) + if language_code == "ru": + return "Π‘Ρ‚ΠΎΠΈΠΌΠΎΡΡ‚ΡŒ ΡƒΠΊΠ°Π·Π°Π½Π° с ΡƒΡ‡Ρ‘Ρ‚ΠΎΠΌ Π²Π°ΡˆΠΈΡ… Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΡ… сСрвСров, Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ° ΠΈ устройств." + return "Prices already include your current servers, traffic, and devices." + + +def _build_promo_offer_payload(user: Optional[User]) -> Optional[Dict[str, Any]]: + percent = get_user_active_promo_discount_percent(user) + if percent <= 0: + return None + + payload: Dict[str, Any] = {"percent": percent} + + expires_at = getattr(user, "promo_offer_discount_expires_at", None) + if expires_at: + payload["expires_at"] = expires_at + + language_code = _normalize_language_code(user) + if language_code == "ru": + payload["message"] = "Π”ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Π°Ρ скидка примСняСтся автоматичСски." + else: + payload["message"] = "Extra discount is applied automatically." + + return payload + + +def _build_renewal_period_id(period_days: int) -> str: + return f"days:{period_days}" + + +def _parse_period_identifier(identifier: Optional[str]) -> Optional[int]: + if not identifier: + return None + + match = _PERIOD_ID_PATTERN.search(str(identifier)) + if not match: + return None + + try: + return int(match.group(1)) + except (TypeError, ValueError): + return None + + +async def _calculate_subscription_renewal_pricing( + db: AsyncSession, + user: User, + subscription: Subscription, + period_days: int, +) -> Dict[str, Any]: + connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])] + server_ids: List[int] = [] + if connected_uuids: + server_ids = await get_server_ids_by_uuids(db, connected_uuids) + + traffic_limit = subscription.traffic_limit_gb + if traffic_limit is None: + traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB + + devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT + + total_cost, details = await calculate_subscription_total_cost( + db, + period_days, + int(traffic_limit or 0), + server_ids, + int(devices_limit or 0), + user=user, + ) + + months = details.get("months_in_period") or calculate_months_from_days(period_days) + + base_original_total = ( + details.get("base_price_original", 0) + + details.get("traffic_price_per_month", 0) * months + + details.get("servers_price_per_month", 0) * months + + details.get("devices_price_per_month", 0) * months + ) + + discounted_total = total_cost + + monthly_additions = 0 + if months > 0: + monthly_additions = ( + details.get("total_servers_price", 0) // months + + details.get("total_devices_price", 0) // months + + details.get("total_traffic_price", 0) // months + ) + + if not validate_pricing_calculation( + details.get("base_price", 0), + monthly_additions, + months, + discounted_total, + ): + logger.warning( + "Renewal pricing validation failed for subscription %s (period %s)", + subscription.id, + period_days, + ) + + promo_percent = get_user_active_promo_discount_percent(user) + final_total = discounted_total + promo_discount_value = 0 + if promo_percent > 0 and discounted_total > 0: + final_total, promo_discount_value = apply_percentage_discount( + discounted_total, + promo_percent, + ) + + overall_discount_value = max(0, base_original_total - final_total) + overall_discount_percent = 0 + if base_original_total > 0 and overall_discount_value > 0: + overall_discount_percent = int( + round(overall_discount_value * 100 / base_original_total) + ) + + per_month = final_total // months if months else final_total + + pricing_payload: Dict[str, Any] = { + "period_id": _build_renewal_period_id(period_days), + "period_days": period_days, + "months": months, + "base_original_total": base_original_total, + "discounted_total": discounted_total, + "final_total": final_total, + "promo_discount_value": promo_discount_value, + "promo_discount_percent": promo_percent if promo_discount_value else 0, + "overall_discount_percent": overall_discount_percent, + "per_month": per_month, + "server_ids": list(server_ids), + "details": details, + } + + return pricing_payload + + +async def _prepare_subscription_renewal_options( + db: AsyncSession, + user: User, + subscription: Subscription, +) -> Tuple[List[MiniAppSubscriptionRenewalPeriod], Dict[Union[str, int], Dict[str, Any]], Optional[str]]: + available_periods = [ + period for period in settings.get_available_renewal_periods() if period > 0 + ] + + option_payloads: List[Tuple[MiniAppSubscriptionRenewalPeriod, Dict[str, Any]]] = [] + + for period_days in available_periods: + try: + pricing = await _calculate_subscription_renewal_pricing( + db, + user, + subscription, + period_days, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.warning( + "Failed to calculate renewal pricing for subscription %s (period %s): %s", + subscription.id, + period_days, + error, + ) + continue + + label = format_period_description( + period_days, + getattr(user, "language", settings.DEFAULT_LANGUAGE), + ) + + price_label = settings.format_price(pricing["final_total"]) + original_label = None + if pricing["base_original_total"] and pricing["base_original_total"] != pricing["final_total"]: + original_label = settings.format_price(pricing["base_original_total"]) + + per_month_label = settings.format_price(pricing["per_month"]) + + option_model = MiniAppSubscriptionRenewalPeriod( + id=pricing["period_id"], + days=period_days, + months=pricing["months"], + price_kopeks=pricing["final_total"], + price_label=price_label, + original_price_kopeks=pricing["base_original_total"], + original_price_label=original_label, + discount_percent=pricing["overall_discount_percent"], + price_per_month_kopeks=pricing["per_month"], + price_per_month_label=per_month_label, + title=label, + ) + + option_payloads.append((option_model, pricing)) + + if not option_payloads: + return [], {}, None + + option_payloads.sort(key=lambda item: item[0].days or 0) + + recommended_option = max( + option_payloads, + key=lambda item: ( + item[1]["overall_discount_percent"], + item[0].months or 0, + -(item[1]["final_total"] or 0), + ), + ) + recommended_option[0].is_recommended = True + + pricing_map: Dict[Union[str, int], Dict[str, Any]] = {} + for option_model, pricing in option_payloads: + pricing_map[option_model.id] = pricing + pricing_map[pricing["period_days"]] = pricing + pricing_map[str(pricing["period_days"])] = pricing + + periods = [item[0] for item in option_payloads] + + return periods, pricing_map, recommended_option[0].id + + +def _get_addon_discount_percent_for_user( + user: Optional[User], + category: str, + period_days_hint: Optional[int] = None, +) -> int: + if user is None: + return 0 + + promo_group = getattr(user, "promo_group", None) + if promo_group is None: + return 0 + + if not getattr(promo_group, "apply_discounts_to_addons", True): + return 0 + + try: + percent = user.get_promo_discount(category, period_days_hint) + except AttributeError: + return 0 + + try: + return int(percent) + except (TypeError, ValueError): + return 0 + + +def _get_period_hint_from_subscription( + subscription: Optional[Subscription], +) -> Optional[int]: + if not subscription: + return None + + months_remaining = get_remaining_months(subscription.end_date) + if months_remaining <= 0: + return None + + return months_remaining * 30 + + +def _validate_subscription_id( + requested_id: Optional[int], + subscription: Subscription, +) -> None: + if requested_id is None: + return + + try: + requested = int(requested_id) + except (TypeError, ValueError): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_subscription_id", + "message": "Invalid subscription identifier", + }, + ) from None + + if requested != subscription.id: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail={ + "code": "subscription_mismatch", + "message": "Subscription does not belong to the authorized user", + }, + ) + + +async def _authorize_miniapp_user( + init_data: str, + db: AsyncSession, +) -> User: + if not init_data: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": "Authorization data is missing"}, + ) + + try: + webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN) + except TelegramWebAppAuthError as error: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": str(error)}, + ) from error + + telegram_user = webapp_data.get("user") + if not isinstance(telegram_user, dict) or "id" not in telegram_user: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, + ) + + try: + telegram_id = int(telegram_user["id"]) + except (TypeError, ValueError): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, + ) from None + + user = await get_user_by_telegram_id(db, telegram_id) + if not user: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"code": "user_not_found", "message": "User not found"}, + ) + + return user + + +def _ensure_paid_subscription(user: User) -> Subscription: + subscription = getattr(user, "subscription", None) + if not subscription: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"code": "subscription_not_found", "message": "Subscription not found"}, + ) + + if getattr(subscription, "is_trial", False): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail={ + "code": "paid_subscription_required", + "message": "This action is available only for paid subscriptions", + }, + ) + + if not getattr(subscription, "is_active", False): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail={ + "code": "subscription_inactive", + "message": "Subscription must be active to manage settings", + }, + ) + + return subscription + + +async def _prepare_server_catalog( + db: AsyncSession, + user: User, + subscription: Subscription, + discount_percent: int, +) -> Tuple[ + List[MiniAppConnectedServer], + List[MiniAppSubscriptionServerOption], + Dict[str, Dict[str, Any]], +]: + available_servers = await get_available_server_squads( + db, + promo_group_id=getattr(user, "promo_group_id", None), + ) + available_by_uuid = {server.squad_uuid: server for server in available_servers} + + current_squads = list(subscription.connected_squads or []) + catalog: Dict[str, Dict[str, Any]] = {} + ordered_uuids: List[str] = [] + + def _register_server(server: Optional[Any], *, is_connected: bool = False) -> None: + if server is None: + return + + uuid = server.squad_uuid + discounted_per_month, discount_per_month = apply_percentage_discount( + int(getattr(server, "price_kopeks", 0) or 0), + discount_percent, + ) + available_for_new = bool(getattr(server, "is_available", True) and not server.is_full) + + entry = catalog.get(uuid) + if entry: + entry.update( + { + "name": getattr(server, "display_name", uuid), + "server_id": getattr(server, "id", None), + "price_per_month": int(getattr(server, "price_kopeks", 0) or 0), + "discounted_per_month": discounted_per_month, + "discount_per_month": discount_per_month, + "available_for_new": available_for_new, + } + ) + entry["is_connected"] = entry["is_connected"] or is_connected + return + + catalog[uuid] = { + "uuid": uuid, + "name": getattr(server, "display_name", uuid), + "server_id": getattr(server, "id", None), + "price_per_month": int(getattr(server, "price_kopeks", 0) or 0), + "discounted_per_month": discounted_per_month, + "discount_per_month": discount_per_month, + "available_for_new": available_for_new, + "is_connected": is_connected, + } + ordered_uuids.append(uuid) + + def _register_placeholder(uuid: str, *, is_connected: bool = False) -> None: + if uuid in catalog: + catalog[uuid]["is_connected"] = catalog[uuid]["is_connected"] or is_connected + return + + catalog[uuid] = { + "uuid": uuid, + "name": uuid, + "server_id": None, + "price_per_month": 0, + "discounted_per_month": 0, + "discount_per_month": 0, + "available_for_new": False, + "is_connected": is_connected, + } + ordered_uuids.append(uuid) + + current_set = set(current_squads) + + for uuid in current_squads: + server = available_by_uuid.get(uuid) + if server: + _register_server(server, is_connected=True) + continue + + server = await get_server_squad_by_uuid(db, uuid) + if server: + _register_server(server, is_connected=True) + else: + _register_placeholder(uuid, is_connected=True) + + for server in available_servers: + _register_server(server, is_connected=server.squad_uuid in current_set) + + current_servers = [ + MiniAppConnectedServer( + uuid=uuid, + name=catalog.get(uuid, {}).get("name", uuid), + ) + for uuid in current_squads + ] + + server_options: List[MiniAppSubscriptionServerOption] = [] + discount_value = discount_percent if discount_percent > 0 else None + + for uuid in ordered_uuids: + entry = catalog[uuid] + available_for_new = bool(entry.get("available_for_new", False)) + is_connected = bool(entry.get("is_connected", False)) + option_available = available_for_new or is_connected + server_options.append( + MiniAppSubscriptionServerOption( + uuid=uuid, + name=entry.get("name", uuid), + price_kopeks=int(entry.get("discounted_per_month", 0)), + price_label=None, + discount_percent=discount_value, + is_connected=is_connected, + is_available=option_available, + disabled_reason=None if option_available else "Server is not available", + ) + ) + + return current_servers, server_options, catalog + + +async def _build_subscription_settings( + db: AsyncSession, + user: User, + subscription: Subscription, +) -> MiniAppSubscriptionSettings: + period_hint_days = _get_period_hint_from_subscription(subscription) + months_remaining = get_remaining_months(subscription.end_date) + servers_discount = _get_addon_discount_percent_for_user( + user, + "servers", + period_hint_days, + ) + traffic_discount = _get_addon_discount_percent_for_user( + user, + "traffic", + period_hint_days, + ) + devices_discount = _get_addon_discount_percent_for_user( + user, + "devices", + period_hint_days, + ) + + current_servers, server_options, _ = await _prepare_server_catalog( + db, + user, + subscription, + servers_discount, + ) + + traffic_options: List[MiniAppSubscriptionTrafficOption] = [] + if settings.is_traffic_selectable(): + for package in settings.get_traffic_packages(): + is_enabled = bool(package.get("enabled", True)) + if package.get("is_active") is False: + is_enabled = False + if not is_enabled: + continue + try: + gb_value = int(package.get("gb")) + except (TypeError, ValueError): + continue + + price = int(package.get("price") or 0) + discounted_price, _ = apply_percentage_discount(price, traffic_discount) + traffic_options.append( + MiniAppSubscriptionTrafficOption( + value=gb_value, + label=None, + price_kopeks=discounted_price, + price_label=None, + is_current=(gb_value == subscription.traffic_limit_gb), + is_available=True, + description=None, + ) + ) + + default_device_limit = max(settings.DEFAULT_DEVICE_LIMIT, 1) + current_device_limit = int(subscription.device_limit or default_device_limit) + + max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None + if max_devices_setting is not None: + max_devices = max(max_devices_setting, current_device_limit, default_device_limit) + else: + max_devices = max(current_device_limit, default_device_limit) + 10 + + discounted_single_device, _ = apply_percentage_discount( + settings.PRICE_PER_DEVICE, + devices_discount, + ) + + devices_options: List[MiniAppSubscriptionDeviceOption] = [] + for value in range(1, max_devices + 1): + chargeable = max(0, value - default_device_limit) + discounted_per_month, _ = apply_percentage_discount( + chargeable * settings.PRICE_PER_DEVICE, + devices_discount, + ) + devices_options.append( + MiniAppSubscriptionDeviceOption( + value=value, + label=None, + price_kopeks=discounted_per_month, + price_label=None, + ) + ) + + settings_payload = MiniAppSubscriptionSettings( + subscription_id=subscription.id, + currency=(getattr(user, "balance_currency", None) or "RUB").upper(), + current=MiniAppSubscriptionCurrentSettings( + servers=current_servers, + traffic_limit_gb=subscription.traffic_limit_gb, + traffic_limit_label=None, + device_limit=current_device_limit, + ), + servers=MiniAppSubscriptionServersSettings( + available=server_options, + min=1 if server_options else 0, + max=len(server_options) if server_options else 0, + can_update=True, + hint=None, + ), + traffic=MiniAppSubscriptionTrafficSettings( + options=traffic_options, + can_update=settings.is_traffic_selectable(), + current_value=subscription.traffic_limit_gb, + ), + devices=MiniAppSubscriptionDevicesSettings( + options=devices_options, + can_update=True, + min=1, + max=max_devices_setting or 0, + step=1, + current=current_device_limit, + price_kopeks=discounted_single_device, + price_label=None, + ), + billing=MiniAppSubscriptionBillingContext( + months_remaining=max(1, months_remaining), + period_hint_days=period_hint_days, + renews_at=subscription.end_date, + ), + ) + + return settings_payload + + +@router.post( + "/subscription/renewal/options", response_model=MiniAppSubscriptionRenewalOptionsResponse, ) async def get_subscription_renewal_options_endpoint( @@ -985,7 +4084,7 @@ async def get_subscription_renewal_options_endpoint( MiniAppPromoGroup( id=promo_group.id, name=promo_group.name, - **extract_promo_discounts(promo_group), + **_extract_promo_discounts(promo_group), ) if promo_group else None @@ -1913,1017 +5012,3 @@ async def update_subscription_devices_endpoint( ) return MiniAppSubscriptionUpdateResponse(success=True) - - -def _normalize_autopay_days(value: Optional[Any]) -> Optional[int]: - if value is None: - return None - try: - numeric = int(value) - except (TypeError, ValueError): - return None - return numeric if numeric >= 0 else None - - -def _get_autopay_day_options(subscription: Optional[Subscription]) -> List[int]: - options: set[int] = set() - for candidate in _AUTOPAY_DEFAULT_DAY_OPTIONS: - normalized = _normalize_autopay_days(candidate) - if normalized is not None: - options.add(normalized) - - default_setting = _normalize_autopay_days( - getattr(settings, "DEFAULT_AUTOPAY_DAYS_BEFORE", None) - ) - if default_setting is not None: - options.add(default_setting) - - if subscription is not None: - current = _normalize_autopay_days( - getattr(subscription, "autopay_days_before", None) - ) - if current is not None: - options.add(current) - - return sorted(options) - - -def _build_autopay_payload( - subscription: Optional[Subscription], -) -> Optional[MiniAppSubscriptionAutopay]: - if subscription is None: - return None - - enabled = bool(getattr(subscription, "autopay_enabled", False)) - days_before = _normalize_autopay_days( - getattr(subscription, "autopay_days_before", None) - ) - options = _get_autopay_day_options(subscription) - - default_days = days_before - if default_days is None: - default_days = _normalize_autopay_days( - getattr(settings, "DEFAULT_AUTOPAY_DAYS_BEFORE", None) - ) - if default_days is None and options: - default_days = options[0] - - autopay_kwargs: Dict[str, Any] = { - "enabled": enabled, - "autopay_enabled": enabled, - "days_before": days_before, - "autopay_days_before": days_before, - "default_days_before": default_days, - "autopay_days_options": options, - "days_options": options, - "options": options, - "available_days": options, - "availableDays": options, - "autopayEnabled": enabled, - "autopayDaysBefore": days_before, - "autopayDaysOptions": options, - "daysBefore": days_before, - "daysOptions": options, - "defaultDaysBefore": default_days, - } - - return MiniAppSubscriptionAutopay(**autopay_kwargs) - - -def _autopay_response_extras( - enabled: bool, - days_before: Optional[int], - options: List[int], - autopay_payload: Optional[MiniAppSubscriptionAutopay], -) -> Dict[str, Any]: - extras: Dict[str, Any] = { - "autopayEnabled": enabled, - "autopayDaysBefore": days_before, - "autopayDaysOptions": options, - } - if days_before is not None: - extras["daysBefore"] = days_before - if options: - extras["daysOptions"] = options - if autopay_payload is not None: - extras["autopaySettings"] = autopay_payload - return extras - - -def _bytes_to_gb(bytes_value: Optional[int]) -> float: - if not bytes_value: - return 0.0 - return round(bytes_value / (1024 ** 3), 2) - - -def _status_label(status: str) -> str: - mapping = { - "active": "Active", - "trial": "Trial", - "expired": "Expired", - "disabled": "Disabled", - } - return mapping.get(status, status.title()) - - -def _parse_datetime_string(value: Optional[str]) -> Optional[str]: - if not value: - return None - - try: - cleaned = value.strip() - if cleaned.endswith("Z"): - cleaned = f"{cleaned[:-1]}+00:00" - if "+00:00+00:00" in cleaned: - cleaned = cleaned.replace("+00:00+00:00", "+00:00") - - datetime.fromisoformat(cleaned) - return cleaned - except Exception: # pragma: no cover - defensive - return value - - -def _resolve_display_name(user_data: Dict[str, Any]) -> str: - username = user_data.get("username") - if username: - return username - - first = user_data.get("first_name") - last = user_data.get("last_name") - parts = [part for part in [first, last] if part] - if parts: - return " ".join(parts) - - telegram_id = user_data.get("telegram_id") - return f"User {telegram_id}" if telegram_id else "User" - - -def _is_remnawave_configured() -> bool: - params = settings.get_remnawave_auth_params() - return bool(params.get("base_url") and params.get("api_key")) - - -def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction: - return MiniAppTransaction( - id=transaction.id, - type=transaction.type, - amount_kopeks=transaction.amount_kopeks, - amount_rubles=round(transaction.amount_kopeks / 100, 2), - description=transaction.description, - payment_method=transaction.payment_method, - external_id=transaction.external_id, - is_completed=transaction.is_completed, - created_at=transaction.created_at, - completed_at=transaction.completed_at, - ) - - -async def _load_subscription_links( - subscription: Subscription, -) -> Dict[str, Any]: - if not subscription.remnawave_short_uuid or not _is_remnawave_configured(): - return {} - - try: - service = SubscriptionService() - info = await service.get_subscription_info(subscription.remnawave_short_uuid) - except Exception as error: # pragma: no cover - defensive logging - logger.warning("Failed to load subscription info from RemnaWave: %s", error) - return {} - - if not info: - return {} - - payload: Dict[str, Any] = { - "links": list(info.links or []), - "ss_conf_links": dict(info.ss_conf_links or {}), - "subscription_url": info.subscription_url, - "happ": info.happ, - "happ_link": getattr(info, "happ_link", None), - "happ_crypto_link": getattr(info, "happ_crypto_link", None), - } - - return payload - - -async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: - remnawave_uuid = getattr(user, "remnawave_uuid", None) - if not remnawave_uuid: - return 0, [] - - try: - service = RemnaWaveService() - except Exception as error: # pragma: no cover - defensive logging - logger.warning("Failed to initialise RemnaWave service: %s", error) - return 0, [] - - if not service.is_configured: - return 0, [] - - try: - async with service.get_api_client() as api: - response = await api.get_user_devices(remnawave_uuid) - except RemnaWaveConfigurationError: - logger.debug("RemnaWave configuration missing while loading devices") - return 0, [] - except Exception as error: # pragma: no cover - defensive logging - logger.warning("Failed to load devices from RemnaWave: %s", error) - return 0, [] - - total_devices = int(response.get("total") or 0) - devices_payload = response.get("devices") or [] - - devices: List[MiniAppDevice] = [] - for device in devices_payload: - hwid = device.get("hwid") or device.get("deviceId") or device.get("id") - platform = device.get("platform") or device.get("platformType") - model = device.get("deviceModel") or device.get("model") or device.get("name") - app_version = device.get("appVersion") or device.get("version") - last_seen_raw = ( - device.get("updatedAt") - or device.get("lastSeen") - or device.get("lastActiveAt") - or device.get("createdAt") - ) - last_ip = device.get("ip") or device.get("ipAddress") - - devices.append( - MiniAppDevice( - hwid=hwid, - platform=platform, - device_model=model, - app_version=app_version, - last_seen=_parse_datetime_string(last_seen_raw), - last_ip=last_ip, - ) - ) - - if total_devices == 0: - total_devices = len(devices) - - return total_devices, devices - - -async def _build_referral_info( - db: AsyncSession, - user: User, -) -> Optional[MiniAppReferralInfo]: - referral_code = getattr(user, "referral_code", None) - referral_settings = settings.get_referral_settings() or {} - - bot_username = settings.get_bot_username() - referral_link = None - if referral_code and bot_username: - referral_link = f"https://t.me/{bot_username}?start={referral_code}" - - minimum_topup_kopeks = int(referral_settings.get("minimum_topup_kopeks") or 0) - first_topup_bonus_kopeks = int(referral_settings.get("first_topup_bonus_kopeks") or 0) - inviter_bonus_kopeks = int(referral_settings.get("inviter_bonus_kopeks") or 0) - commission_percent = float(referral_settings.get("commission_percent") or 0) - - referred_user_reward_kopeks = settings.get_referred_user_reward_kopeks() - for key in ("referred_user_reward_kopeks", "referred_user_reward"): - candidate = referral_settings.get(key) - if candidate is None: - continue - try: - value = int(candidate) - except (TypeError, ValueError): - continue - if value <= 0: - referred_user_reward_kopeks = 0 - break - if key == "referred_user_reward" and value < 1000: - value *= 100 - referred_user_reward_kopeks = value - break - - terms = MiniAppReferralTerms( - minimum_topup_kopeks=minimum_topup_kopeks, - minimum_topup_label=settings.format_price(minimum_topup_kopeks), - first_topup_bonus_kopeks=first_topup_bonus_kopeks, - first_topup_bonus_label=settings.format_price(first_topup_bonus_kopeks), - inviter_bonus_kopeks=inviter_bonus_kopeks, - inviter_bonus_label=settings.format_price(inviter_bonus_kopeks), - commission_percent=commission_percent, - referred_user_reward_kopeks=referred_user_reward_kopeks, - referred_user_reward_label=settings.format_price(referred_user_reward_kopeks), - ) - - summary = await get_user_referral_summary(db, user.id) - stats: Optional[MiniAppReferralStats] = None - recent_earnings: List[MiniAppReferralRecentEarning] = [] - - if summary: - total_earned_kopeks = int(summary.get("total_earned_kopeks") or 0) - month_earned_kopeks = int(summary.get("month_earned_kopeks") or 0) - - stats = MiniAppReferralStats( - invited_count=int(summary.get("invited_count") or 0), - paid_referrals_count=int(summary.get("paid_referrals_count") or 0), - active_referrals_count=int(summary.get("active_referrals_count") or 0), - total_earned_kopeks=total_earned_kopeks, - total_earned_label=settings.format_price(total_earned_kopeks), - month_earned_kopeks=month_earned_kopeks, - month_earned_label=settings.format_price(month_earned_kopeks), - conversion_rate=float(summary.get("conversion_rate") or 0.0), - ) - - for earning in summary.get("recent_earnings", []) or []: - amount = int(earning.get("amount_kopeks") or 0) - recent_earnings.append( - MiniAppReferralRecentEarning( - amount_kopeks=amount, - amount_label=settings.format_price(amount), - reason=earning.get("reason"), - referral_name=earning.get("referral_name"), - created_at=earning.get("created_at"), - ) - ) - - detailed = await get_detailed_referral_list(db, user.id, limit=50, offset=0) - referral_items: List[MiniAppReferralItem] = [] - if detailed: - for item in detailed.get("referrals", []) or []: - total_earned = int(item.get("total_earned_kopeks") or 0) - balance = int(item.get("balance_kopeks") or 0) - referral_items.append( - MiniAppReferralItem( - id=int(item.get("id") or 0), - telegram_id=item.get("telegram_id"), - full_name=item.get("full_name"), - username=item.get("username"), - created_at=item.get("created_at"), - last_activity=item.get("last_activity"), - has_made_first_topup=bool(item.get("has_made_first_topup")), - balance_kopeks=balance, - balance_label=settings.format_price(balance), - total_earned_kopeks=total_earned, - total_earned_label=settings.format_price(total_earned), - topups_count=int(item.get("topups_count") or 0), - days_since_registration=item.get("days_since_registration"), - days_since_activity=item.get("days_since_activity"), - status=item.get("status"), - ) - ) - - referral_list = MiniAppReferralList( - total_count=int(detailed.get("total_count") or 0) if detailed else 0, - has_next=bool(detailed.get("has_next")) if detailed else False, - has_prev=bool(detailed.get("has_prev")) if detailed else False, - current_page=int(detailed.get("current_page") or 1) if detailed else 1, - total_pages=int(detailed.get("total_pages") or 1) if detailed else 1, - items=referral_items, - ) - - if ( - not referral_code - and not referral_link - and not referral_items - and not recent_earnings - and (not stats or (stats.invited_count == 0 and stats.total_earned_kopeks == 0)) - ): - return None - - return MiniAppReferralInfo( - referral_code=referral_code, - referral_link=referral_link, - terms=terms, - stats=stats, - recent_earnings=recent_earnings, - referrals=referral_list, - ) - - -def _is_trial_available_for_user(user: User) -> bool: - if settings.TRIAL_DURATION_DAYS <= 0: - return False - - if getattr(user, "has_had_paid_subscription", False): - return False - - subscription = getattr(user, "subscription", None) - if subscription is not None: - return False - - return True - - -def _normalize_language_code(user: Optional[User]) -> str: - language = getattr(user, "language", None) or settings.DEFAULT_LANGUAGE or "ru" - return language.split("-")[0].lower() - - -def _build_renewal_status_message(user: Optional[User]) -> str: - language_code = _normalize_language_code(user) - if language_code == "ru": - return "Π‘Ρ‚ΠΎΠΈΠΌΠΎΡΡ‚ΡŒ ΡƒΠΊΠ°Π·Π°Π½Π° с ΡƒΡ‡Ρ‘Ρ‚ΠΎΠΌ Π²Π°ΡˆΠΈΡ… Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΡ… сСрвСров, Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ° ΠΈ устройств." - return "Prices already include your current servers, traffic, and devices." - - -def _build_promo_offer_payload(user: Optional[User]) -> Optional[Dict[str, Any]]: - percent = get_user_active_promo_discount_percent(user) - if percent <= 0: - return None - - payload: Dict[str, Any] = {"percent": percent} - - expires_at = getattr(user, "promo_offer_discount_expires_at", None) - if expires_at: - payload["expires_at"] = expires_at - - language_code = _normalize_language_code(user) - if language_code == "ru": - payload["message"] = "Π”ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Π°Ρ скидка примСняСтся автоматичСски." - else: - payload["message"] = "Extra discount is applied automatically." - - return payload - - -def _build_renewal_period_id(period_days: int) -> str: - return f"days:{period_days}" - - -def _parse_period_identifier(identifier: Optional[str]) -> Optional[int]: - if not identifier: - return None - - match = _PERIOD_ID_PATTERN.search(str(identifier)) - if not match: - return None - - try: - return int(match.group(1)) - except (TypeError, ValueError): - return None - - -async def _calculate_subscription_renewal_pricing( - db: AsyncSession, - user: User, - subscription: Subscription, - period_days: int, -) -> Dict[str, Any]: - connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])] - server_ids: List[int] = [] - if connected_uuids: - server_ids = await get_server_ids_by_uuids(db, connected_uuids) - - traffic_limit = subscription.traffic_limit_gb - if traffic_limit is None: - traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB - - devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT - - total_cost, details = await calculate_subscription_total_cost( - db, - period_days, - int(traffic_limit or 0), - server_ids, - int(devices_limit or 0), - user=user, - ) - - months = details.get("months_in_period") or calculate_months_from_days(period_days) - - base_original_total = ( - details.get("base_price_original", 0) - + details.get("traffic_price_per_month", 0) * months - + details.get("servers_price_per_month", 0) * months - + details.get("devices_price_per_month", 0) * months - ) - - discounted_total = total_cost - - monthly_additions = 0 - if months > 0: - monthly_additions = ( - details.get("total_servers_price", 0) // months - + details.get("total_devices_price", 0) // months - + details.get("total_traffic_price", 0) // months - ) - - if not validate_pricing_calculation( - details.get("base_price", 0), - monthly_additions, - months, - discounted_total, - ): - logger.warning( - "Renewal pricing validation failed for subscription %s (period %s)", - subscription.id, - period_days, - ) - - promo_percent = get_user_active_promo_discount_percent(user) - final_total = discounted_total - promo_discount_value = 0 - if promo_percent > 0 and discounted_total > 0: - final_total, promo_discount_value = apply_percentage_discount( - discounted_total, - promo_percent, - ) - - overall_discount_value = max(0, base_original_total - final_total) - overall_discount_percent = 0 - if base_original_total > 0 and overall_discount_value > 0: - overall_discount_percent = int( - round(overall_discount_value * 100 / base_original_total) - ) - - per_month = final_total // months if months else final_total - - pricing_payload: Dict[str, Any] = { - "period_id": _build_renewal_period_id(period_days), - "period_days": period_days, - "months": months, - "base_original_total": base_original_total, - "discounted_total": discounted_total, - "final_total": final_total, - "promo_discount_value": promo_discount_value, - "promo_discount_percent": promo_percent if promo_discount_value else 0, - "overall_discount_percent": overall_discount_percent, - "per_month": per_month, - "server_ids": list(server_ids), - "details": details, - } - - return pricing_payload - - -async def _prepare_subscription_renewal_options( - db: AsyncSession, - user: User, - subscription: Subscription, -) -> Tuple[ - List[MiniAppSubscriptionRenewalPeriod], - Dict[Union[str, int], Dict[str, Any]], - Optional[str], -]: - available_periods = [ - period for period in settings.get_available_renewal_periods() if period > 0 - ] - - option_payloads: List[Tuple[MiniAppSubscriptionRenewalPeriod, Dict[str, Any]]] = [] - - for period_days in available_periods: - try: - pricing = await _calculate_subscription_renewal_pricing( - db, - user, - subscription, - period_days, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.warning( - "Failed to calculate renewal pricing for subscription %s (period %s): %s", - subscription.id, - period_days, - error, - ) - continue - - label = format_period_description( - period_days, - getattr(user, "language", settings.DEFAULT_LANGUAGE), - ) - - price_label = settings.format_price(pricing["final_total"]) - original_label = None - if pricing["base_original_total"] and pricing["base_original_total"] != pricing["final_total"]: - original_label = settings.format_price(pricing["base_original_total"]) - - per_month_label = settings.format_price(pricing["per_month"]) - - option_model = MiniAppSubscriptionRenewalPeriod( - id=pricing["period_id"], - days=period_days, - months=pricing["months"], - price_kopeks=pricing["final_total"], - price_label=price_label, - original_price_kopeks=pricing["base_original_total"], - original_price_label=original_label, - discount_percent=pricing["overall_discount_percent"], - price_per_month_kopeks=pricing["per_month"], - price_per_month_label=per_month_label, - title=label, - ) - - option_payloads.append((option_model, pricing)) - - if not option_payloads: - return [], {}, None - - option_payloads.sort(key=lambda item: item[0].days or 0) - - recommended_option = max( - option_payloads, - key=lambda item: ( - item[1]["overall_discount_percent"], - item[0].months or 0, - -(item[1]["final_total"] or 0), - ), - ) - recommended_option[0].is_recommended = True - - pricing_map: Dict[Union[str, int], Dict[str, Any]] = {} - for option_model, pricing in option_payloads: - pricing_map[option_model.id] = pricing - pricing_map[pricing["period_days"]] = pricing - pricing_map[str(pricing["period_days"])] = pricing - - periods = [item[0] for item in option_payloads] - - return periods, pricing_map, recommended_option[0].id - - -def _get_addon_discount_percent_for_user( - user: Optional[User], - category: str, - period_days_hint: Optional[int] = None, -) -> int: - if user is None: - return 0 - - promo_group = getattr(user, "promo_group", None) - if promo_group is None: - return 0 - - if not getattr(promo_group, "apply_discounts_to_addons", True): - return 0 - - try: - percent = user.get_promo_discount(category, period_days_hint) - except AttributeError: - return 0 - - try: - return int(percent) - except (TypeError, ValueError): - return 0 - - -def _get_period_hint_from_subscription( - subscription: Optional[Subscription], -) -> Optional[int]: - if not subscription: - return None - - months_remaining = get_remaining_months(subscription.end_date) - if months_remaining <= 0: - return None - - return months_remaining * 30 - - -async def _prepare_server_catalog( - db: AsyncSession, - user: User, - subscription: Subscription, - discount_percent: int, -) -> Tuple[ - List[MiniAppConnectedServer], - List[MiniAppSubscriptionServerOption], - Dict[str, Dict[str, Any]], -]: - available_servers = await get_available_server_squads( - db, - promo_group_id=getattr(user, "promo_group_id", None), - ) - available_by_uuid = {server.squad_uuid: server for server in available_servers} - - current_squads = list(subscription.connected_squads or []) - catalog: Dict[str, Dict[str, Any]] = {} - ordered_uuids: List[str] = [] - - def _register_server(server: Optional[Any], *, is_connected: bool = False) -> None: - if server is None: - return - - uuid = server.squad_uuid - discounted_per_month, discount_per_month = apply_percentage_discount( - int(getattr(server, "price_kopeks", 0) or 0), - discount_percent, - ) - available_for_new = bool(getattr(server, "is_available", True) and not server.is_full) - - entry = catalog.get(uuid) - if entry: - entry.update( - { - "name": getattr(server, "display_name", uuid), - "server_id": getattr(server, "id", None), - "price_per_month": int(getattr(server, "price_kopeks", 0) or 0), - "discounted_per_month": discounted_per_month, - "discount_per_month": discount_per_month, - "available_for_new": available_for_new, - } - ) - entry["is_connected"] = entry["is_connected"] or is_connected - return - - catalog[uuid] = { - "uuid": uuid, - "name": getattr(server, "display_name", uuid), - "server_id": getattr(server, "id", None), - "price_per_month": int(getattr(server, "price_kopeks", 0) or 0), - "discounted_per_month": discounted_per_month, - "discount_per_month": discount_per_month, - "available_for_new": available_for_new, - "is_connected": is_connected, - } - ordered_uuids.append(uuid) - - def _register_placeholder(uuid: str, *, is_connected: bool = False) -> None: - if uuid in catalog: - catalog[uuid]["is_connected"] = catalog[uuid]["is_connected"] or is_connected - return - - catalog[uuid] = { - "uuid": uuid, - "name": uuid, - "server_id": None, - "price_per_month": 0, - "discounted_per_month": 0, - "discount_per_month": 0, - "available_for_new": False, - "is_connected": is_connected, - } - ordered_uuids.append(uuid) - - current_set = set(current_squads) - - for uuid in current_squads: - server = available_by_uuid.get(uuid) - if server: - _register_server(server, is_connected=True) - continue - - server = await get_server_squad_by_uuid(db, uuid) - if server: - _register_server(server, is_connected=True) - else: - _register_placeholder(uuid, is_connected=True) - - for server in available_servers: - _register_server(server, is_connected=server.squad_uuid in current_set) - - current_servers = [ - MiniAppConnectedServer( - uuid=uuid, - name=catalog.get(uuid, {}).get("name", uuid), - ) - for uuid in current_squads - ] - - server_options: List[MiniAppSubscriptionServerOption] = [] - discount_value = discount_percent if discount_percent > 0 else None - - for uuid in ordered_uuids: - entry = catalog[uuid] - available_for_new = bool(entry.get("available_for_new", False)) - is_connected = bool(entry.get("is_connected", False)) - option_available = available_for_new or is_connected - server_options.append( - MiniAppSubscriptionServerOption( - uuid=uuid, - name=entry.get("name", uuid), - price_kopeks=int(entry.get("discounted_per_month", 0)), - price_label=None, - discount_percent=discount_value, - is_connected=is_connected, - is_available=option_available, - disabled_reason=None if option_available else "Server is not available", - ) - ) - - return current_servers, server_options, catalog - - -async def _build_subscription_settings( - db: AsyncSession, - user: User, - subscription: Subscription, -) -> MiniAppSubscriptionSettings: - period_hint_days = _get_period_hint_from_subscription(subscription) - months_remaining = get_remaining_months(subscription.end_date) - servers_discount = _get_addon_discount_percent_for_user( - user, - "servers", - period_hint_days, - ) - traffic_discount = _get_addon_discount_percent_for_user( - user, - "traffic", - period_hint_days, - ) - devices_discount = _get_addon_discount_percent_for_user( - user, - "devices", - period_hint_days, - ) - - current_servers, server_options, _ = await _prepare_server_catalog( - db, - user, - subscription, - servers_discount, - ) - - traffic_options: List[MiniAppSubscriptionTrafficOption] = [] - if settings.is_traffic_selectable(): - for package in settings.get_traffic_packages(): - is_enabled = bool(package.get("enabled", True)) - if package.get("is_active") is False: - is_enabled = False - if not is_enabled: - continue - try: - gb_value = int(package.get("gb")) - except (TypeError, ValueError): - continue - - price = int(package.get("price") or 0) - discounted_price, _ = apply_percentage_discount(price, traffic_discount) - traffic_options.append( - MiniAppSubscriptionTrafficOption( - value=gb_value, - label=None, - price_kopeks=discounted_price, - price_label=None, - is_current=(gb_value == subscription.traffic_limit_gb), - is_available=True, - description=None, - ) - ) - - default_device_limit = max(settings.DEFAULT_DEVICE_LIMIT, 1) - current_device_limit = int(subscription.device_limit or default_device_limit) - - max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None - if max_devices_setting is not None: - max_devices = max(max_devices_setting, current_device_limit, default_device_limit) - else: - max_devices = max(current_device_limit, default_device_limit) + 10 - - discounted_single_device, _ = apply_percentage_discount( - settings.PRICE_PER_DEVICE, - devices_discount, - ) - - devices_options: List[MiniAppSubscriptionDeviceOption] = [] - for value in range(1, max_devices + 1): - chargeable = max(0, value - default_device_limit) - discounted_per_month, _ = apply_percentage_discount( - chargeable * settings.PRICE_PER_DEVICE, - devices_discount, - ) - devices_options.append( - MiniAppSubscriptionDeviceOption( - value=value, - label=None, - price_kopeks=discounted_per_month, - price_label=None, - ) - ) - - settings_payload = MiniAppSubscriptionSettings( - subscription_id=subscription.id, - currency=(getattr(user, "balance_currency", None) or "RUB").upper(), - current=MiniAppSubscriptionCurrentSettings( - servers=current_servers, - traffic_limit_gb=subscription.traffic_limit_gb, - traffic_limit_label=None, - device_limit=current_device_limit, - ), - servers=MiniAppSubscriptionServersSettings( - available=server_options, - min=1 if server_options else 0, - max=len(server_options) if server_options else 0, - can_update=True, - hint=None, - ), - traffic=MiniAppSubscriptionTrafficSettings( - options=traffic_options, - can_update=settings.is_traffic_selectable(), - current_value=subscription.traffic_limit_gb, - ), - devices=MiniAppSubscriptionDevicesSettings( - options=devices_options, - can_update=True, - min=1, - max=max_devices_setting or 0, - step=1, - current=current_device_limit, - price_kopeks=discounted_single_device, - price_label=None, - ), - billing=MiniAppSubscriptionBillingContext( - months_remaining=max(1, months_remaining), - period_hint_days=period_hint_days, - renews_at=subscription.end_date, - ), - ) - - return settings_payload - - -def _validate_subscription_id( - requested_id: Optional[int], - subscription: Subscription, -) -> None: - if requested_id is None: - return - - try: - requested = int(requested_id) - except (TypeError, ValueError): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={ - "code": "invalid_subscription_id", - "message": "Invalid subscription identifier", - }, - ) from None - - if requested != subscription.id: - raise HTTPException( - status.HTTP_403_FORBIDDEN, - detail={ - "code": "subscription_mismatch", - "message": "Subscription does not belong to the authorized user", - }, - ) - - -async def _authorize_miniapp_user( - init_data: str, - db: AsyncSession, -) -> User: - if not init_data: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": "Authorization data is missing"}, - ) - - try: - webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN) - except TelegramWebAppAuthError as error: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": str(error)}, - ) from error - - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, - ) - - try: - telegram_id = int(telegram_user["id"]) - except (TypeError, ValueError): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, - ) from None - - user = await get_user_by_telegram_id(db, telegram_id) - if not user: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail={"code": "user_not_found", "message": "User not found"}, - ) - - return user - - -def _ensure_paid_subscription(user: User) -> Subscription: - subscription = getattr(user, "subscription", None) - if not subscription: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail={"code": "subscription_not_found", "message": "Subscription not found"}, - ) - - if getattr(subscription, "is_trial", False): - raise HTTPException( - status.HTTP_403_FORBIDDEN, - detail={ - "code": "paid_subscription_required", - "message": "This action is available only for paid subscriptions", - }, - ) - - if not getattr(subscription, "is_active", False): - raise HTTPException( - status.HTTP_403_FORBIDDEN, - detail={ - "code": "subscription_inactive", - "message": "Subscription must be active to manage settings", - }, - ) - - return subscription diff --git a/app/webapi/routes/miniapp/__init__.py b/app/webapi/routes/miniapp/__init__.py deleted file mode 100644 index a23a9051..00000000 --- a/app/webapi/routes/miniapp/__init__.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -import sys -from types import ModuleType - -from fastapi import APIRouter - -from . import payments, promo, subscription -from ._state import state - -__all__ = [ - "router", - "PaymentService", - "Bot", - "get_wata_payment_by_link_id", - "create_payment_link", - "get_payment_methods", - "get_payment_statuses", - "_compute_cryptobot_limits", - "_find_recent_deposit", - "_resolve_payment_status_entry", - "_resolve_yookassa_payment_status", - "_resolve_mulenpay_payment_status", - "_resolve_wata_payment_status", - "_resolve_pal24_payment_status", - "_resolve_cryptobot_payment_status", - "_resolve_stars_payment_status", - "_resolve_tribute_payment_status", - "_resolve_user_from_init_data", -] - -router = APIRouter() -router.include_router(payments.router) -router.include_router(promo.router) -router.include_router(subscription.router) - -create_payment_link = payments.create_payment_link -get_payment_methods = payments.get_payment_methods -get_payment_statuses = payments.get_payment_statuses - -_compute_cryptobot_limits = payments._compute_cryptobot_limits -_find_recent_deposit = payments._find_recent_deposit -_resolve_payment_status_entry = payments._resolve_payment_status_entry -_resolve_yookassa_payment_status = payments._resolve_yookassa_payment_status -_resolve_mulenpay_payment_status = payments._resolve_mulenpay_payment_status -_resolve_wata_payment_status = payments._resolve_wata_payment_status -_resolve_pal24_payment_status = payments._resolve_pal24_payment_status -_resolve_cryptobot_payment_status = payments._resolve_cryptobot_payment_status -_resolve_stars_payment_status = payments._resolve_stars_payment_status -_resolve_tribute_payment_status = payments._resolve_tribute_payment_status -_resolve_user_from_init_data = payments._resolve_user_from_init_data - -_STATE_ATTRS = {"PaymentService", "Bot", "get_wata_payment_by_link_id"} -_FORWARDED_ATTRS = {"_resolve_user_from_init_data"} - -for attr in _STATE_ATTRS: - globals()[attr] = getattr(state, attr) - - -class _MiniappModule(ModuleType): - def __setattr__(self, name: str, value): # type: ignore[override] - if name in _STATE_ATTRS: - setattr(state, name, value) - if name in _FORWARDED_ATTRS: - setattr(payments, name, value) - super().__setattr__(name, value) - - -def _patch_module_type() -> None: - module = sys.modules[__name__] - if not isinstance(module, _MiniappModule): - module.__class__ = _MiniappModule # type: ignore[assignment] - - -_patch_module_type() diff --git a/app/webapi/routes/miniapp/_state.py b/app/webapi/routes/miniapp/_state.py deleted file mode 100644 index 388214bc..00000000 --- a/app/webapi/routes/miniapp/_state.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from aiogram import Bot - -from app.services.payment_service import PaymentService, get_wata_payment_by_link_id - - -class MiniAppState: - """Holds mutable dependencies shared across miniapp modules.""" - - def __init__(self) -> None: - self.PaymentService = PaymentService - self.Bot = Bot - self.get_wata_payment_by_link_id = get_wata_payment_by_link_id - - -state = MiniAppState() - -__all__ = ["state", "MiniAppState"] diff --git a/app/webapi/routes/miniapp/payments.py b/app/webapi/routes/miniapp/payments.py deleted file mode 100644 index 579178d5..00000000 --- a/app/webapi/routes/miniapp/payments.py +++ /dev/null @@ -1,1348 +0,0 @@ -from __future__ import annotations - -import logging -import math -from decimal import Decimal, InvalidOperation, ROUND_FLOOR, ROUND_HALF_UP -from datetime import datetime, timedelta, timezone -from uuid import uuid4 -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union - -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.crud.user import get_user_by_telegram_id -from app.database.models import PaymentMethod, Transaction, TransactionType, User -from app.services.tribute_service import TributeService -from app.utils.currency_converter import currency_converter -from app.utils.telegram_webapp import TelegramWebAppAuthError, parse_webapp_init_data - -from ._state import state - -if TYPE_CHECKING: # pragma: no cover - imported for type checking only - from aiogram import Bot as AiogramBot - from app.services.payment_service import PaymentService as PaymentServiceProtocol - -from ...dependencies import get_db_session -from ...schemas.miniapp import ( - MiniAppPaymentCreateRequest, - MiniAppPaymentCreateResponse, - MiniAppPaymentMethod, - MiniAppPaymentMethodsRequest, - MiniAppPaymentMethodsResponse, - MiniAppPaymentStatusQuery, - MiniAppPaymentStatusRequest, - MiniAppPaymentStatusResponse, - MiniAppPaymentStatusResult, -) - - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -_CRYPTOBOT_MIN_USD = 1.0 -_CRYPTOBOT_MAX_USD = 1000.0 -_CRYPTOBOT_FALLBACK_RATE = 95.0 - -_DECIMAL_ONE_HUNDRED = Decimal(100) -_DECIMAL_CENT = Decimal("0.01") - -_PAYMENT_SUCCESS_STATUSES = { - "paid", - "success", - "succeeded", - "completed", - "captured", - "done", - "overpaid", -} -_PAYMENT_FAILURE_STATUSES = { - "fail", - "failed", - "canceled", - "cancelled", - "declined", - "expired", - "rejected", - "error", - "refunded", - "chargeback", -} - - -async def _resolve_user_from_init_data( - db: AsyncSession, - init_data: str, -) -> Tuple[User, Dict[str, Any]]: - if not init_data: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail="Missing initData", - ) - - try: - webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN) - except TelegramWebAppAuthError as error: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail=str(error), - ) from error - - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Invalid Telegram user payload", - ) - - try: - telegram_id = int(telegram_user["id"]) - except (TypeError, ValueError): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Invalid Telegram user identifier", - ) from None - - user = await get_user_by_telegram_id(db, telegram_id) - if not user: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail="User not found", - ) - - return user, webapp_data - - -def _normalize_amount_kopeks( - amount_rubles: Optional[float], - amount_kopeks: Optional[int], -) -> Optional[int]: - if amount_kopeks is not None: - try: - normalized = int(amount_kopeks) - except (TypeError, ValueError): - return None - return normalized if normalized >= 0 else None - - if amount_rubles is None: - return None - - try: - decimal_amount = Decimal(str(amount_rubles)).quantize( - Decimal("0.01"), rounding=ROUND_HALF_UP - ) - except (InvalidOperation, ValueError): - return None - - normalized = int((decimal_amount * 100).to_integral_value(rounding=ROUND_HALF_UP)) - return normalized if normalized >= 0 else None - - -def _build_balance_invoice_payload(user_id: int, amount_kopeks: int) -> str: - suffix = uuid4().hex[:8] - return f"balance_{user_id}_{amount_kopeks}_{suffix}" - - -def _current_request_timestamp() -> str: - return datetime.utcnow().replace(microsecond=0).isoformat() - - -def _compute_stars_min_amount() -> Optional[int]: - try: - rate = Decimal(str(settings.get_stars_rate())) - except (InvalidOperation, TypeError): - return None - - if rate <= 0: - return None - - return int((rate * _DECIMAL_ONE_HUNDRED).to_integral_value(rounding=ROUND_HALF_UP)) - - -def _normalize_stars_amount(amount_kopeks: int) -> Tuple[int, int]: - try: - rate = Decimal(str(settings.get_stars_rate())) - except (InvalidOperation, TypeError): - raise ValueError("Stars rate is not configured") - - if rate <= 0: - raise ValueError("Stars rate must be positive") - - amount_rubles = Decimal(amount_kopeks) / _DECIMAL_ONE_HUNDRED - stars_amount = int((amount_rubles / rate).to_integral_value(rounding=ROUND_FLOOR)) - if stars_amount <= 0: - stars_amount = 1 - - normalized_rubles = (Decimal(stars_amount) * rate).quantize( - _DECIMAL_CENT, - rounding=ROUND_HALF_UP, - ) - normalized_amount_kopeks = int( - (normalized_rubles * _DECIMAL_ONE_HUNDRED).to_integral_value( - rounding=ROUND_HALF_UP - ) - ) - - return stars_amount, normalized_amount_kopeks - - -async def _get_usd_to_rub_rate() -> float: - try: - rate = await currency_converter.get_usd_to_rub_rate() - except Exception: - rate = 0.0 - if not rate or rate <= 0: - rate = _CRYPTOBOT_FALLBACK_RATE - return float(rate) - - -def _compute_cryptobot_limits(rate: float) -> Tuple[int, int]: - min_kopeks = max(1, int(math.ceil(rate * _CRYPTOBOT_MIN_USD * 100))) - max_kopeks = int(math.floor(rate * _CRYPTOBOT_MAX_USD * 100)) - if max_kopeks < min_kopeks: - max_kopeks = min_kopeks - return min_kopeks, max_kopeks - - -def _parse_client_timestamp(value: Optional[Union[str, int, float]]) -> Optional[datetime]: - if value is None: - return None - if isinstance(value, (int, float)): - try: - timestamp = float(value) - except (TypeError, ValueError): - return None - if timestamp > 1e12: - timestamp /= 1000.0 - try: - return datetime.fromtimestamp(timestamp, tz=timezone.utc).replace(tzinfo=None) - except (OverflowError, OSError, ValueError): - return None - if isinstance(value, str): - normalized = value.strip() - if not normalized: - return None - if normalized.isdigit(): - return _parse_client_timestamp(int(normalized)) - for suffix in ("Z", "z"): - if normalized.endswith(suffix): - normalized = normalized[:-1] + "+00:00" - break - try: - parsed = datetime.fromisoformat(normalized) - except ValueError: - return None - if parsed.tzinfo: - return parsed.astimezone(timezone.utc).replace(tzinfo=None) - return parsed - return None - - -async def _find_recent_deposit( - db: AsyncSession, - *, - user_id: int, - payment_method: PaymentMethod, - amount_kopeks: Optional[int], - started_at: Optional[datetime], - tolerance: timedelta = timedelta(minutes=5), -) -> Optional[Transaction]: - def _transaction_matches_started_at( - transaction: Transaction, - reference: Optional[datetime], - ) -> bool: - if not reference: - return True - timestamp = transaction.completed_at or transaction.created_at - if not timestamp: - return False - if timestamp.tzinfo: - timestamp = timestamp.astimezone(timezone.utc).replace(tzinfo=None) - return timestamp >= reference - - query = ( - select(Transaction) - .where( - Transaction.user_id == user_id, - Transaction.type == TransactionType.DEPOSIT.value, - Transaction.payment_method == payment_method.value, - ) - .order_by(Transaction.created_at.desc()) - .limit(1) - ) - - if amount_kopeks is not None: - query = query.where(Transaction.amount_kopeks == amount_kopeks) - if started_at: - query = query.where(Transaction.created_at >= started_at - tolerance) - - result = await db.execute(query) - transaction = result.scalar_one_or_none() - - if not transaction: - return None - - if not _transaction_matches_started_at(transaction, started_at): - return None - - return transaction - - -def _classify_status(status: Optional[str], is_paid: bool) -> str: - if is_paid: - return "paid" - normalized = (status or "").strip().lower() - if not normalized: - return "pending" - if normalized in _PAYMENT_SUCCESS_STATUSES: - return "paid" - if normalized in _PAYMENT_FAILURE_STATUSES: - return "failed" - return "pending" - - -@router.post( - "/payments/methods", - response_model=MiniAppPaymentMethodsResponse, -) -async def get_payment_methods( - payload: MiniAppPaymentMethodsRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppPaymentMethodsResponse: - _, _ = await _resolve_user_from_init_data(db, payload.init_data) - - methods: List[MiniAppPaymentMethod] = [] - - if settings.TELEGRAM_STARS_ENABLED: - stars_min_amount = _compute_stars_min_amount() - methods.append( - MiniAppPaymentMethod( - id="stars", - icon="⭐", - requires_amount=True, - currency="RUB", - min_amount_kopeks=stars_min_amount, - amount_step_kopeks=stars_min_amount, - ) - ) - - if settings.is_yookassa_enabled(): - if getattr(settings, "YOOKASSA_SBP_ENABLED", False): - methods.append( - MiniAppPaymentMethod( - id="yookassa_sbp", - icon="🏦", - requires_amount=True, - currency="RUB", - min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, - ) - ) - - methods.append( - MiniAppPaymentMethod( - id="yookassa", - icon="πŸ’³", - requires_amount=True, - currency="RUB", - min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, - ) - ) - - if settings.is_mulenpay_enabled(): - methods.append( - MiniAppPaymentMethod( - id="mulenpay", - icon="πŸ’³", - requires_amount=True, - currency="RUB", - min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS, - ) - ) - - if settings.is_pal24_enabled(): - methods.append( - MiniAppPaymentMethod( - id="pal24", - icon="🏦", - requires_amount=True, - currency="RUB", - min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, - ) - ) - - if settings.is_wata_enabled(): - methods.append( - MiniAppPaymentMethod( - id="wata", - icon="🌊", - requires_amount=True, - currency="RUB", - min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS, - ) - ) - - if settings.is_cryptobot_enabled(): - rate = await _get_usd_to_rub_rate() - min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate) - methods.append( - MiniAppPaymentMethod( - id="cryptobot", - icon="πŸͺ™", - requires_amount=True, - currency="RUB", - min_amount_kopeks=min_amount_kopeks, - max_amount_kopeks=max_amount_kopeks, - ) - ) - - if settings.TRIBUTE_ENABLED: - methods.append( - MiniAppPaymentMethod( - id="tribute", - icon="πŸ’Ž", - requires_amount=False, - currency="RUB", - ) - ) - - order_map = { - "stars": 1, - "yookassa_sbp": 2, - "yookassa": 3, - "mulenpay": 4, - "pal24": 5, - "wata": 6, - "cryptobot": 7, - "tribute": 8, - } - methods.sort(key=lambda item: order_map.get(item.id, 99)) - - return MiniAppPaymentMethodsResponse(methods=methods) - - -@router.post( - "/payments/create", - response_model=MiniAppPaymentCreateResponse, -) -async def create_payment_link( - payload: MiniAppPaymentCreateRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppPaymentCreateResponse: - user, _ = await _resolve_user_from_init_data(db, payload.init_data) - - method = (payload.method or "").strip().lower() - if not method: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Payment method is required", - ) - - amount_kopeks = _normalize_amount_kopeks( - payload.amount_rubles, - payload.amount_kopeks, - ) - - if method == "stars": - if not settings.TELEGRAM_STARS_ENABLED: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") - if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") - if not settings.BOT_TOKEN: - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured") - - requested_amount_kopeks = amount_kopeks - try: - stars_amount, amount_kopeks = _normalize_stars_amount(amount_kopeks) - except ValueError as exc: - logger.error("Failed to normalize Stars amount: %s", exc) - raise HTTPException( - status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to prepare Stars payment", - ) from exc - - bot = state.Bot(token=settings.BOT_TOKEN) - invoice_payload = _build_balance_invoice_payload(user.id, amount_kopeks) - try: - payment_service = state.PaymentService(bot) - invoice_link = await payment_service.create_stars_invoice( - amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), - payload=invoice_payload, - stars_amount=stars_amount, - ) - finally: - await bot.session.close() - - if not invoice_link: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create invoice") - - return MiniAppPaymentCreateResponse( - method=method, - payment_url=invoice_link, - amount_kopeks=amount_kopeks, - extra={ - "invoice_payload": invoice_payload, - "requested_at": _current_request_timestamp(), - "stars_amount": stars_amount, - "requested_amount_kopeks": requested_amount_kopeks, - }, - ) - - if method == "yookassa_sbp": - if not settings.is_yookassa_enabled() or not getattr(settings, "YOOKASSA_SBP_ENABLED", False): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") - if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") - if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") - if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") - - payment_service = state.PaymentService() - result = await payment_service.create_yookassa_sbp_payment( - db=db, - user_id=user.id, - amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), - ) - confirmation_url = result.get("confirmation_url") if result else None - if not result or not confirmation_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") - - extra: dict[str, Any] = { - "local_payment_id": result.get("local_payment_id"), - "payment_id": result.get("yookassa_payment_id"), - "status": result.get("status"), - "requested_at": _current_request_timestamp(), - } - confirmation_token = result.get("confirmation_token") - if confirmation_token: - extra["confirmation_token"] = confirmation_token - - return MiniAppPaymentCreateResponse( - method=method, - payment_url=confirmation_url, - amount_kopeks=amount_kopeks, - extra=extra, - ) - - if method == "yookassa": - if not settings.is_yookassa_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") - if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") - if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") - if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") - - payment_service = state.PaymentService() - result = await payment_service.create_yookassa_payment( - db=db, - user_id=user.id, - amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), - ) - if not result or not result.get("confirmation_url"): - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") - - return MiniAppPaymentCreateResponse( - method=method, - payment_url=result["confirmation_url"], - amount_kopeks=amount_kopeks, - extra={ - "local_payment_id": result.get("local_payment_id"), - "payment_id": result.get("yookassa_payment_id"), - "status": result.get("status"), - "requested_at": _current_request_timestamp(), - }, - ) - - if method == "mulenpay": - if not settings.is_mulenpay_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") - if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") - if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") - if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") - - payment_service = state.PaymentService() - result = await payment_service.create_mulenpay_payment( - db=db, - user_id=user.id, - amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), - language=user.language, - ) - if not result or not result.get("payment_url"): - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") - - return MiniAppPaymentCreateResponse( - method=method, - payment_url=result["payment_url"], - amount_kopeks=amount_kopeks, - extra={ - "local_payment_id": result.get("local_payment_id"), - "payment_id": result.get("mulen_payment_id"), - "requested_at": _current_request_timestamp(), - }, - ) - - if method == "wata": - if not settings.is_wata_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") - if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") - if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") - if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") - - payment_service = state.PaymentService() - result = await payment_service.create_wata_payment( - db=db, - user_id=user.id, - amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), - language=user.language, - ) - payment_url = result.get("payment_url") if result else None - if not result or not payment_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") - - return MiniAppPaymentCreateResponse( - method=method, - payment_url=payment_url, - amount_kopeks=amount_kopeks, - extra={ - "local_payment_id": result.get("local_payment_id"), - "payment_link_id": result.get("payment_link_id"), - "payment_id": result.get("payment_link_id"), - "status": result.get("status"), - "order_id": result.get("order_id"), - "requested_at": _current_request_timestamp(), - }, - ) - - if method == "pal24": - if not settings.is_pal24_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") - if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") - if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") - if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") - - option = (payload.payment_option or "").strip().lower() - if option not in {"card", "sbp"}: - option = "sbp" - provider_method = "CARD" if option == "card" else "SBP" - - payment_service = state.PaymentService() - result = await payment_service.create_pal24_payment( - db=db, - user_id=user.id, - amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), - language=user.language or settings.DEFAULT_LANGUAGE, - payment_method=provider_method, - ) - if not result: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") - - preferred_urls: List[Optional[str]] = [] - if option == "sbp": - preferred_urls.append(result.get("sbp_url")) - elif option == "card": - preferred_urls.append(result.get("card_url")) - preferred_urls.extend( - [ - result.get("link_url"), - result.get("link_page_url"), - result.get("payment_url"), - result.get("transfer_url"), - ] - ) - payment_url = next((url for url in preferred_urls if url), None) - if not payment_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url") - - return MiniAppPaymentCreateResponse( - method=method, - payment_url=payment_url, - amount_kopeks=amount_kopeks, - extra={ - "local_payment_id": result.get("local_payment_id"), - "bill_id": result.get("bill_id"), - "order_id": result.get("order_id"), - "payment_method": result.get("payment_method") or provider_method, - "sbp_url": result.get("sbp_url"), - "card_url": result.get("card_url"), - "link_url": result.get("link_url"), - "link_page_url": result.get("link_page_url"), - "transfer_url": result.get("transfer_url"), - "selected_option": option, - "requested_at": _current_request_timestamp(), - }, - ) - - if method == "cryptobot": - if not settings.is_cryptobot_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") - if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") - rate = await _get_usd_to_rub_rate() - min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate) - if amount_kopeks < min_amount_kopeks: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail=f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)", - ) - if amount_kopeks > max_amount_kopeks: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail=f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)", - ) - - try: - amount_usd = float( - (Decimal(amount_kopeks) / Decimal(100) / Decimal(str(rate))) - .quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - ) - except (InvalidOperation, ValueError): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Unable to convert amount to USD", - ) - - payment_service = state.PaymentService() - result = await payment_service.create_cryptobot_payment( - db=db, - user_id=user.id, - amount_usd=amount_usd, - asset=settings.CRYPTOBOT_DEFAULT_ASSET, - description=settings.get_balance_payment_description(amount_kopeks), - payload=f"balance_{user.id}_{amount_kopeks}", - ) - if not result: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") - - payment_url = ( - result.get("bot_invoice_url") - or result.get("mini_app_invoice_url") - or result.get("web_app_invoice_url") - ) - if not payment_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url") - - return MiniAppPaymentCreateResponse( - method=method, - payment_url=payment_url, - amount_kopeks=amount_kopeks, - extra={ - "local_payment_id": result.get("local_payment_id"), - "invoice_id": result.get("invoice_id"), - "amount_usd": amount_usd, - "rate": rate, - "requested_at": _current_request_timestamp(), - }, - ) - - if method == "tribute": - if not settings.TRIBUTE_ENABLED: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") - if not settings.BOT_TOKEN: - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured") - - bot = state.Bot(token=settings.BOT_TOKEN) - try: - tribute_service = TributeService(bot) - payment_url = await tribute_service.create_payment_link( - user_id=user.telegram_id, - amount_kopeks=amount_kopeks or 0, - description=settings.get_balance_payment_description(amount_kopeks or 0), - ) - finally: - await bot.session.close() - - if not payment_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") - - return MiniAppPaymentCreateResponse( - method=method, - payment_url=payment_url, - amount_kopeks=amount_kopeks, - extra={ - "requested_at": _current_request_timestamp(), - }, - ) - - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unknown payment method") - - -@router.post( - "/payments/status", - response_model=MiniAppPaymentStatusResponse, -) -async def get_payment_statuses( - payload: MiniAppPaymentStatusRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppPaymentStatusResponse: - user, _ = await _resolve_user_from_init_data(db, payload.init_data) - - entries = payload.payments or [] - if not entries: - return MiniAppPaymentStatusResponse(results=[]) - - payment_service = state.PaymentService() - results: List[MiniAppPaymentStatusResult] = [] - - for entry in entries: - result = await _resolve_payment_status_entry( - payment_service=payment_service, - db=db, - user=user, - query=entry, - ) - if result: - results.append(result) - - return MiniAppPaymentStatusResponse(results=results) - - -async def _resolve_payment_status_entry( - *, - payment_service: PaymentServiceProtocol, - db: AsyncSession, - user: User, - query: MiniAppPaymentStatusQuery, -) -> MiniAppPaymentStatusResult: - method = (query.method or "").strip().lower() - if not method: - return MiniAppPaymentStatusResult( - method="", - status="unknown", - message="Payment method is required", - ) - - if method in {"yookassa", "yookassa_sbp"}: - return await _resolve_yookassa_payment_status( - db, - user, - query, - method=method, - ) - if method == "mulenpay": - return await _resolve_mulenpay_payment_status(payment_service, db, user, query) - if method == "wata": - return await _resolve_wata_payment_status(payment_service, db, user, query) - if method == "pal24": - return await _resolve_pal24_payment_status(payment_service, db, user, query) - if method == "cryptobot": - return await _resolve_cryptobot_payment_status(db, user, query) - if method == "stars": - return await _resolve_stars_payment_status(db, user, query) - if method == "tribute": - return await _resolve_tribute_payment_status(db, user, query) - - return MiniAppPaymentStatusResult( - method=method, - status="unknown", - message="Unsupported payment method", - ) - - -async def _resolve_yookassa_payment_status( - db: AsyncSession, - user: User, - query: MiniAppPaymentStatusQuery, - *, - method: str = "yookassa", -) -> MiniAppPaymentStatusResult: - from app.database.crud.yookassa import ( - get_yookassa_payment_by_id, - get_yookassa_payment_by_local_id, - ) - - payment = None - if query.local_payment_id: - payment = await get_yookassa_payment_by_local_id(db, query.local_payment_id) - if not payment and query.payment_id: - payment = await get_yookassa_payment_by_id(db, query.payment_id) - - if not payment or payment.user_id != user.id: - return MiniAppPaymentStatusResult( - method=method, - status="pending", - is_paid=False, - amount_kopeks=query.amount_kopeks, - message="Payment not found", - extra={ - "local_payment_id": query.local_payment_id, - "payment_id": query.payment_id, - "invoice_id": query.payment_id, - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - succeeded = bool(payment.is_paid and (payment.status or "").lower() == "succeeded") - status_value = _classify_status(payment.status, succeeded) - completed_at = payment.captured_at or payment.updated_at or payment.created_at - - return MiniAppPaymentStatusResult( - method=method, - status=status_value, - is_paid=status_value == "paid", - amount_kopeks=payment.amount_kopeks, - currency=payment.currency, - completed_at=completed_at, - transaction_id=payment.transaction_id, - external_id=payment.yookassa_payment_id, - extra={ - "status": payment.status, - "is_paid": payment.is_paid, - "local_payment_id": payment.id, - "payment_id": payment.yookassa_payment_id, - "invoice_id": payment.yookassa_payment_id, - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - -async def _resolve_mulenpay_payment_status( - payment_service: PaymentServiceProtocol, - db: AsyncSession, - user: User, - query: MiniAppPaymentStatusQuery, -) -> MiniAppPaymentStatusResult: - if not query.local_payment_id: - return MiniAppPaymentStatusResult( - method="mulenpay", - status="pending", - is_paid=False, - amount_kopeks=query.amount_kopeks, - message="Missing payment identifier", - extra={ - "local_payment_id": query.local_payment_id, - "invoice_id": query.invoice_id, - "payment_id": query.payment_id, - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - status_info = await payment_service.get_mulenpay_payment_status( - db, - query.local_payment_id, - ) - payment = status_info.get("payment") if status_info else None - - if not payment or payment.user_id != user.id: - return MiniAppPaymentStatusResult( - method="mulenpay", - status="pending", - is_paid=False, - amount_kopeks=query.amount_kopeks, - message="Payment not found", - extra={ - "local_payment_id": query.local_payment_id, - "invoice_id": query.invoice_id, - "payment_id": query.payment_id, - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - status_raw = status_info.get("status") if status_info else None - if not status_raw: - status_raw = payment.status - is_paid = bool(payment.is_paid) - status_value = _classify_status(status_raw, is_paid) - completed_at = payment.paid_at or payment.updated_at or payment.created_at - message = None - if status_value == "failed": - remote_status = None - if status_info: - remote_status = status_info.get("remote_status_code") or status_info.get("remote_status") - if not remote_status: - remote_status = status_raw - if remote_status: - message = f"Status: {remote_status}" - - return MiniAppPaymentStatusResult( - method="mulenpay", - status=status_value, - is_paid=status_value == "paid", - amount_kopeks=payment.amount_kopeks, - currency=payment.currency, - completed_at=completed_at, - transaction_id=payment.transaction_id, - external_id=str(payment.mulen_payment_id or payment.uuid), - message=message, - extra={ - "status": payment.status, - "remote_status": status_info.get("remote_status_code") if status_info else None, - "local_payment_id": payment.id, - "payment_id": payment.mulen_payment_id, - "uuid": str(payment.uuid), - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - -async def _resolve_wata_payment_status( - payment_service: PaymentServiceProtocol, - db: AsyncSession, - user: User, - query: MiniAppPaymentStatusQuery, -) -> MiniAppPaymentStatusResult: - async def _maybe_get_payment_by_link_id(link_id: Optional[str | int]): - if not link_id: - return None - try: - return await state.get_wata_payment_by_link_id(db, link_id) - except AttributeError: - # Tests may call the resolver with ``db=None``. In that case the real - # CRUD helper would try to access ``db.execute`` on ``None``. Swallow - # the error so that monkeypatched helpers (used in tests) can still - # be invoked while production code keeps the original behaviour when - # a real session is supplied. - if db is None: - return None - raise - - local_id = query.local_payment_id - payment_link_id = query.payment_link_id or query.payment_id or query.invoice_id - fallback_payment = None - - if not local_id and payment_link_id: - fallback_payment = await _maybe_get_payment_by_link_id(payment_link_id) - if fallback_payment: - local_id = fallback_payment.id - - if not local_id: - return MiniAppPaymentStatusResult( - method="wata", - status="pending", - is_paid=False, - amount_kopeks=query.amount_kopeks, - message="Missing payment identifier", - extra={ - "local_payment_id": query.local_payment_id, - "payment_link_id": payment_link_id, - "payment_id": query.payment_id, - "invoice_id": query.invoice_id, - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - status_info = await payment_service.get_wata_payment_status(db, local_id) - payment = (status_info or {}).get("payment") or fallback_payment - - if not payment or payment.user_id != user.id: - return MiniAppPaymentStatusResult( - method="wata", - status="pending", - is_paid=False, - amount_kopeks=query.amount_kopeks, - message="Payment not found", - extra={ - "local_payment_id": local_id, - "payment_link_id": (payment_link_id or getattr(payment, "payment_link_id", None)), - "payment_id": query.payment_id, - "invoice_id": query.invoice_id, - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - remote_link = (status_info or {}).get("remote_link") if status_info else None - transaction_payload = (status_info or {}).get("transaction") if status_info else None - status_raw = (status_info or {}).get("status") or getattr(payment, "status", None) - is_paid_flag = bool((status_info or {}).get("is_paid") or getattr(payment, "is_paid", False)) - status_value = _classify_status(status_raw, is_paid_flag) - completed_at = ( - getattr(payment, "paid_at", None) - or getattr(payment, "updated_at", None) - or getattr(payment, "created_at", None) - ) - - message = None - if status_value == "failed": - message = ( - (transaction_payload or {}).get("errorDescription") - or (transaction_payload or {}).get("errorCode") - or (remote_link or {}).get("status") - ) - - extra: Dict[str, Any] = { - "local_payment_id": payment.id, - "payment_link_id": payment.payment_link_id, - "payment_id": payment.payment_link_id, - "status": status_raw, - "is_paid": getattr(payment, "is_paid", False), - "order_id": getattr(payment, "order_id", None), - "payload": query.payload, - "started_at": query.started_at, - } - if remote_link: - extra["remote_link"] = remote_link - if transaction_payload: - extra["transaction"] = transaction_payload - - return MiniAppPaymentStatusResult( - method="wata", - status=status_value, - is_paid=status_value == "paid", - amount_kopeks=payment.amount_kopeks, - currency=payment.currency, - completed_at=completed_at, - transaction_id=payment.transaction_id, - external_id=payment.payment_link_id, - message=message, - extra=extra, - ) - - -async def _resolve_pal24_payment_status( - payment_service: PaymentServiceProtocol, - db: AsyncSession, - user: User, - query: MiniAppPaymentStatusQuery, -) -> MiniAppPaymentStatusResult: - from app.database.crud.pal24 import get_pal24_payment_by_bill_id - - local_id = query.local_payment_id - if not local_id and query.invoice_id: - payment_by_bill = await get_pal24_payment_by_bill_id(db, query.invoice_id) - if payment_by_bill and payment_by_bill.user_id == user.id: - local_id = payment_by_bill.id - - if not local_id: - return MiniAppPaymentStatusResult( - method="pal24", - status="pending", - is_paid=False, - amount_kopeks=query.amount_kopeks, - message="Missing payment identifier", - extra={ - "local_payment_id": query.local_payment_id, - "bill_id": query.invoice_id, - "order_id": None, - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - status_info = await payment_service.get_pal24_payment_status(db, local_id) - payment = status_info.get("payment") if status_info else None - - if not payment or payment.user_id != user.id: - return MiniAppPaymentStatusResult( - method="pal24", - status="pending", - is_paid=False, - amount_kopeks=query.amount_kopeks, - message="Payment not found", - extra={ - "local_payment_id": local_id, - "bill_id": query.invoice_id, - "order_id": None, - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - status_raw = status_info.get("status") or payment.status - is_paid = bool(payment.is_paid) - status_value = _classify_status(status_raw, is_paid) - completed_at = payment.paid_at or payment.updated_at or payment.created_at - message = None - if status_value == "failed": - remote_status = status_info.get("remote_status") or status_raw - if remote_status: - message = f"Status: {remote_status}" - - return MiniAppPaymentStatusResult( - method="pal24", - status=status_value, - is_paid=status_value == "paid", - amount_kopeks=payment.amount_kopeks, - currency=payment.currency, - completed_at=completed_at, - transaction_id=payment.transaction_id, - external_id=payment.bill_id, - message=message, - extra={ - "status": payment.status, - "remote_status": status_info.get("remote_status"), - "local_payment_id": payment.id, - "bill_id": payment.bill_id, - "order_id": payment.order_id, - "payment_method": getattr(payment, "payment_method", None), - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - -async def _resolve_cryptobot_payment_status( - db: AsyncSession, - user: User, - query: MiniAppPaymentStatusQuery, -) -> MiniAppPaymentStatusResult: - from app.database.crud.cryptobot import ( - get_cryptobot_payment_by_id, - get_cryptobot_payment_by_invoice_id, - ) - - payment = None - if query.local_payment_id: - payment = await get_cryptobot_payment_by_id(db, query.local_payment_id) - if not payment and query.invoice_id: - payment = await get_cryptobot_payment_by_invoice_id(db, query.invoice_id) - - if not payment or payment.user_id != user.id: - return MiniAppPaymentStatusResult( - method="cryptobot", - status="pending", - is_paid=False, - amount_kopeks=query.amount_kopeks, - message="Payment not found", - extra={ - "local_payment_id": query.local_payment_id, - "invoice_id": query.invoice_id, - "payment_id": query.payment_id, - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - status_raw = payment.status - is_paid = bool(payment.is_paid) - status_value = _classify_status(status_raw, is_paid) - completed_at = payment.paid_at or payment.updated_at or payment.created_at - amount_kopeks = payment.amount_rub_kopeks or query.amount_kopeks - - return MiniAppPaymentStatusResult( - method="cryptobot", - status=status_value, - is_paid=status_value == "paid", - amount_kopeks=amount_kopeks, - currency=payment.asset, - completed_at=completed_at, - transaction_id=payment.transaction_id, - external_id=payment.invoice_id, - extra={ - "status": payment.status, - "asset": payment.asset, - "local_payment_id": payment.id, - "invoice_id": payment.invoice_id, - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - -async def _resolve_stars_payment_status( - db: AsyncSession, - user: User, - query: MiniAppPaymentStatusQuery, -) -> MiniAppPaymentStatusResult: - started_at = _parse_client_timestamp(query.started_at) - transaction = await _find_recent_deposit( - db, - user_id=user.id, - payment_method=PaymentMethod.TELEGRAM_STARS, - amount_kopeks=query.amount_kopeks, - started_at=started_at, - ) - - if not transaction: - return MiniAppPaymentStatusResult( - method="stars", - status="pending", - is_paid=False, - amount_kopeks=query.amount_kopeks, - message="Waiting for confirmation", - extra={ - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - return MiniAppPaymentStatusResult( - method="stars", - status="paid", - is_paid=True, - amount_kopeks=transaction.amount_kopeks, - currency="RUB", - completed_at=transaction.completed_at or transaction.created_at, - transaction_id=transaction.id, - external_id=transaction.external_id, - extra={ - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - -async def _resolve_tribute_payment_status( - db: AsyncSession, - user: User, - query: MiniAppPaymentStatusQuery, -) -> MiniAppPaymentStatusResult: - started_at = _parse_client_timestamp(query.started_at) - transaction = await _find_recent_deposit( - db, - user_id=user.id, - payment_method=PaymentMethod.TRIBUTE, - amount_kopeks=query.amount_kopeks, - started_at=started_at, - ) - - if not transaction: - return MiniAppPaymentStatusResult( - method="tribute", - status="pending", - is_paid=False, - amount_kopeks=query.amount_kopeks, - message="Waiting for confirmation", - extra={ - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - return MiniAppPaymentStatusResult( - method="tribute", - status="paid", - is_paid=True, - amount_kopeks=transaction.amount_kopeks, - currency="RUB", - completed_at=transaction.completed_at or transaction.created_at, - transaction_id=transaction.id, - external_id=transaction.external_id, - extra={ - "payload": query.payload, - "started_at": query.started_at, - }, - ) - - -__all__ = ["router"] diff --git a/app/webapi/routes/miniapp/promo.py b/app/webapi/routes/miniapp/promo.py deleted file mode 100644 index e736cbf2..00000000 --- a/app/webapi/routes/miniapp/promo.py +++ /dev/null @@ -1,631 +0,0 @@ -from __future__ import annotations - -import logging -import re -from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple - -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.server_squad import get_server_squad_by_uuid -from app.database.crud.user import get_user_by_telegram_id -from app.database.models import PromoGroup, PromoOfferTemplate, Subscription, User -from app.services.promo_offer_service import promo_offer_service -from app.services.promocode_service import PromoCodeService -from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService -from app.utils.telegram_webapp import TelegramWebAppAuthError, parse_webapp_init_data - -from ...dependencies import get_db_session -from ...schemas.miniapp import ( - MiniAppConnectedServer, - MiniAppPromoCode, - MiniAppPromoCodeActivationRequest, - MiniAppPromoCodeActivationResponse, - MiniAppPromoOffer, - MiniAppPromoOfferClaimRequest, - MiniAppPromoOfferClaimResponse, -) - - -logger = logging.getLogger(__name__) - -router = APIRouter() - -promo_code_service = PromoCodeService() - -_TEMPLATE_ID_PATTERN = re.compile(r"promo_template_(?P\d+)$") -_OFFER_TYPE_ICONS = { - "extend_discount": "πŸ’Ž", - "purchase_discount": "🎯", - "test_access": "πŸ§ͺ", -} -_EFFECT_TYPE_ICONS = { - "percent_discount": "🎁", - "test_access": "πŸ§ͺ", - "balance_bonus": "πŸ’°", -} -_DEFAULT_OFFER_ICON = "πŸŽ‰" - -ActiveOfferContext = Tuple[Any, Optional[int], Optional[datetime]] - - -async def resolve_connected_servers( - db: AsyncSession, - squad_uuids: List[str], -) -> List[MiniAppConnectedServer]: - if not squad_uuids: - return [] - - resolved: Dict[str, str] = {} - missing: List[str] = [] - - for squad_uuid in squad_uuids: - if squad_uuid in resolved: - continue - server = await get_server_squad_by_uuid(db, squad_uuid) - if server and server.display_name: - resolved[squad_uuid] = server.display_name - else: - missing.append(squad_uuid) - - if missing: - try: - service = RemnaWaveService() - if service.is_configured: - squads = await service.get_all_squads() - for squad in squads: - uuid = squad.get("uuid") - name = squad.get("name") - if uuid in missing and name: - resolved[uuid] = name - except RemnaWaveConfigurationError: - logger.debug("RemnaWave is not configured; skipping server name enrichment") - except Exception as error: # pragma: no cover - defensive logging - logger.warning("Failed to resolve server names from RemnaWave: %s", error) - - connected_servers: List[MiniAppConnectedServer] = [] - for squad_uuid in squad_uuids: - name = resolved.get(squad_uuid, squad_uuid) - connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name)) - - return connected_servers - - -def _extract_template_id(notification_type: Optional[str]) -> Optional[int]: - if not notification_type: - return None - match = _TEMPLATE_ID_PATTERN.search(notification_type) - if not match: - return None - try: - return int(match.group("template_id")) - except (TypeError, ValueError): - return None - - -def _extract_offer_extra(offer: Any) -> Dict[str, Any]: - extra = getattr(offer, "extra", None) - if isinstance(extra, dict): - return extra - return {} - - -def _extract_offer_type( - offer: Any, - template: Optional[PromoOfferTemplate], -) -> Optional[str]: - extra = _extract_offer_extra(offer) - offer_type = extra.get("offer_type") - if isinstance(offer_type, str) and offer_type.strip(): - return offer_type.strip() - if template and isinstance(template.offer_type, str) and template.offer_type.strip(): - return template.offer_type.strip() - notification_type = getattr(offer, "notification_type", None) - if isinstance(notification_type, str) and notification_type.strip(): - return notification_type.strip() - return None - - -def _extract_offer_test_squad_uuids(offer: Any) -> List[str]: - extra = _extract_offer_extra(offer) - squads = extra.get("test_squad_uuids") or extra.get("test_squads") - if isinstance(squads, list): - return [str(uuid) for uuid in squads if uuid] - squad = extra.get("test_squad_uuid") - if squad: - return [str(squad)] - return [] - - -def _normalize_effect_type(effect_type: Optional[str]) -> Optional[str]: - if not effect_type: - return None - normalized = effect_type.replace("-", "_").strip().lower() - if normalized in {"percent_discount", "test_access", "balance_bonus"}: - return normalized - return effect_type - - -def _format_bonus_label(amount_kopeks: int) -> Optional[str]: - if amount_kopeks <= 0: - return None - return settings.format_price(amount_kopeks) - - -def _format_offer_message( - template: Optional[PromoOfferTemplate], - offer: Any, - *, - server_name: Optional[str] = None, -) -> Optional[str]: - extra = _extract_offer_extra(offer) - message_text = extra.get("message_text") or extra.get("message") - if isinstance(message_text, str) and message_text.strip(): - return message_text.strip() - if template and isinstance(template.message_text, str): - message = template.message_text.strip() - if message: - return message - if server_name: - return f"Доступ Π½Π° сСрвСр {server_name}" - return None - - -def extract_promo_discounts(group: PromoGroup) -> Dict[str, Any]: - result: Dict[str, Any] = {} - - try: - purchase_discount = int(group.purchase_discount_percent or 0) - except (TypeError, ValueError): - purchase_discount = 0 - try: - extend_discount = int(group.extend_discount_percent or 0) - except (TypeError, ValueError): - extend_discount = 0 - - result["purchase_discount_percent"] = max(0, purchase_discount) - result["extend_discount_percent"] = max(0, extend_discount) - return result - - -async def build_promo_offer_models( - db: AsyncSession, - available_offers: List[Any], - active_offers: Optional[List[ActiveOfferContext]], - *, - user: User, -) -> List[MiniAppPromoOffer]: - promo_offers: List[MiniAppPromoOffer] = [] - template_cache: Dict[int, Optional[PromoOfferTemplate]] = {} - - candidates: List[Any] = [offer for offer in available_offers if offer] - active_offer_contexts: List[ActiveOfferContext] = [] - if active_offers: - for offer, discount_override, expires_override in active_offers: - if not offer: - continue - active_offer_contexts.append((offer, discount_override, expires_override)) - candidates.append(offer) - - squad_map: Dict[str, MiniAppConnectedServer] = {} - if candidates: - all_uuids: List[str] = [] - for offer in candidates: - all_uuids.extend(_extract_offer_test_squad_uuids(offer)) - if all_uuids: - unique = list(dict.fromkeys(all_uuids)) - resolved = await resolve_connected_servers(db, unique) - squad_map = {server.uuid: server for server in resolved} - - async def get_template(template_id: Optional[int]) -> Optional[PromoOfferTemplate]: - if not template_id: - return None - if template_id not in template_cache: - template_cache[template_id] = await get_promo_offer_template_by_id(db, template_id) - return template_cache[template_id] - - def build_test_squads(offer: Any) -> List[MiniAppConnectedServer]: - test_squads: List[MiniAppConnectedServer] = [] - for uuid in _extract_offer_test_squad_uuids(offer): - resolved = squad_map.get(uuid) - if resolved: - test_squads.append( - MiniAppConnectedServer(uuid=resolved.uuid, name=resolved.name) - ) - else: - test_squads.append(MiniAppConnectedServer(uuid=uuid, name=uuid)) - return test_squads - - def resolve_title( - offer: Any, - template: Optional[PromoOfferTemplate], - offer_type: Optional[str], - ) -> Optional[str]: - extra = _extract_offer_extra(offer) - if isinstance(extra.get("title"), str) and extra["title"].strip(): - return extra["title"].strip() - if template and template.name: - return template.name - if offer_type: - return offer_type.replace("_", " ").title() - return None - - for offer in available_offers: - template_id = _extract_template_id(getattr(offer, "notification_type", None)) - template = await get_template(template_id) - effect_type = _normalize_effect_type(getattr(offer, "effect_type", None)) - offer_type = _extract_offer_type(offer, template) - test_squads = build_test_squads(offer) - server_name = test_squads[0].name if test_squads else None - message_text = _format_offer_message(template, offer, server_name=server_name) - bonus_label = _format_bonus_label(int(getattr(offer, "bonus_amount_kopeks", 0) or 0)) - discount_percent = getattr(offer, "discount_percent", 0) - try: - discount_percent = int(discount_percent) - except (TypeError, ValueError): - discount_percent = 0 - - extra = _extract_offer_extra(offer) - button_text = None - if isinstance(extra.get("button_text"), str) and extra["button_text"].strip(): - button_text = extra["button_text"].strip() - elif template and isinstance(template.button_text, str): - button_text = template.button_text - - promo_offers.append( - MiniAppPromoOffer( - id=int(getattr(offer, "id", 0) or 0), - status="pending", - notification_type=getattr(offer, "notification_type", None), - offer_type=offer_type, - effect_type=effect_type, - discount_percent=max(0, discount_percent), - discount_percent_override=None, - bonus_amount_label=bonus_label, - message_text=message_text, - title=resolve_title(offer, template, offer_type), - icon=_OFFER_TYPE_ICONS.get(offer_type) or _EFFECT_TYPE_ICONS.get(effect_type) or _DEFAULT_OFFER_ICON, - expires_at=getattr(offer, "expires_at", None), - claimed_at=getattr(offer, "claimed_at", None), - bonus_amount_kopeks=int(getattr(offer, "bonus_amount_kopeks", 0) or 0), - promo_offer_template_id=template_id, - test_squads=test_squads, - extra=extra, - ) - ) - - if active_offer_contexts: - offer_map: Dict[int, Tuple[Any, Optional[int], Optional[datetime]]] = {} - for offer, discount_override, expires_override in active_offer_contexts: - offer_id = getattr(offer, "id", None) - if not isinstance(offer_id, int): - continue - current = offer_map.get(offer_id) - if current is None: - offer_map[offer_id] = (offer, discount_override, expires_override) - else: - _, _, current_expires = current - if (current_expires or datetime.min) < (expires_override or datetime.max): - offer_map[offer_id] = (offer, discount_override, expires_override) - - for offer_id, (offer, discount_override, expires_override) in offer_map.items(): - for candidate in promo_offers: - if candidate.id != offer_id: - continue - if discount_override is not None: - candidate.discount_percent_override = max(0, discount_override) - if expires_override is not None: - candidate.expires_at = min(candidate.expires_at or expires_override, expires_override) - candidate.status = "active" - break - - return promo_offers - - -async def find_active_test_access_offers( - db: AsyncSession, - subscription: Subscription, -) -> List[ActiveOfferContext]: - if not subscription: - return [] - - query = ( - await promo_offer_service.get_test_access_entries( - db, - subscription.id, - include_expired=False, - ) - ) - - now = datetime.utcnow() - offer_map: Dict[int, Tuple[Any, Optional[datetime]]] = {} - - for entry in query: - offer = getattr(entry, "offer", None) - if not offer: - continue - - effect_type = _normalize_effect_type(getattr(offer, "effect_type", None)) - if effect_type != "test_access": - continue - - expires_at = getattr(entry, "expires_at", None) - if not expires_at or expires_at <= now: - continue - - offer_id = getattr(offer, "id", None) - if not isinstance(offer_id, int): - continue - - current = offer_map.get(offer_id) - if current is None: - offer_map[offer_id] = (offer, expires_at) - else: - _, current_expiry = current - if current_expiry is None or (expires_at and expires_at > current_expiry): - offer_map[offer_id] = (offer, expires_at) - - contexts: List[ActiveOfferContext] = [] - for offer_id, (offer, expires_at) in offer_map.items(): - contexts.append((offer, None, expires_at)) - - contexts.sort(key=lambda item: item[2] or now, reverse=True) - return contexts - - -@router.post( - "/promo-codes/activate", - response_model=MiniAppPromoCodeActivationResponse, -) -async def activate_promo_code( - payload: MiniAppPromoCodeActivationRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppPromoCodeActivationResponse: - try: - webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) - except TelegramWebAppAuthError as error: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": str(error)}, - ) from error - - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, - ) - - try: - telegram_id = int(telegram_user["id"]) - except (TypeError, ValueError): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, - ) from None - - user = await get_user_by_telegram_id(db, telegram_id) - if not user: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail={"code": "user_not_found", "message": "User not found"}, - ) - - code = (payload.code or "").strip().upper() - if not code: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid", "message": "Promo code must not be empty"}, - ) - - result = await promo_code_service.activate_promocode(db, user.id, code) - if result.get("success"): - promocode_data = result.get("promocode") or {} - - try: - balance_bonus = int(promocode_data.get("balance_bonus_kopeks") or 0) - except (TypeError, ValueError): - balance_bonus = 0 - - try: - subscription_days = int(promocode_data.get("subscription_days") or 0) - except (TypeError, ValueError): - subscription_days = 0 - - promo_payload = MiniAppPromoCode( - code=str(promocode_data.get("code") or code), - type=promocode_data.get("type"), - balance_bonus_kopeks=balance_bonus, - subscription_days=subscription_days, - max_uses=promocode_data.get("max_uses"), - current_uses=promocode_data.get("current_uses"), - valid_until=promocode_data.get("valid_until"), - ) - - return MiniAppPromoCodeActivationResponse( - success=True, - description=result.get("description"), - promocode=promo_payload, - ) - - error_code = str(result.get("error") or "generic") - status_map = { - "user_not_found": status.HTTP_404_NOT_FOUND, - "not_found": status.HTTP_404_NOT_FOUND, - "expired": status.HTTP_410_GONE, - "used": status.HTTP_409_CONFLICT, - "already_used_by_user": status.HTTP_409_CONFLICT, - "server_error": status.HTTP_500_INTERNAL_SERVER_ERROR, - } - message_map = { - "invalid": "Promo code must not be empty", - "not_found": "Promo code not found", - "expired": "Promo code expired", - "used": "Promo code already used", - "already_used_by_user": "Promo code already used by this user", - "user_not_found": "User not found", - "server_error": "Failed to activate promo code", - } - - http_status = status_map.get(error_code, status.HTTP_400_BAD_REQUEST) - message = message_map.get(error_code, "Unable to activate promo code") - - raise HTTPException( - http_status, - detail={"code": error_code, "message": message}, - ) - - -@router.post( - "/promo-offers/{offer_id}/claim", - response_model=MiniAppPromoOfferClaimResponse, -) -async def claim_promo_offer( - offer_id: int, - payload: MiniAppPromoOfferClaimRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppPromoOfferClaimResponse: - try: - webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) - except TelegramWebAppAuthError as error: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": str(error)}, - ) from error - - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, - ) - - try: - telegram_id = int(telegram_user["id"]) - except (TypeError, ValueError): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, - ) from None - - user = await get_user_by_telegram_id(db, telegram_id) - if not user: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail={"code": "user_not_found", "message": "User not found"}, - ) - - offer = await get_offer_by_id(db, offer_id) - if not offer or offer.user_id != user.id: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail={"code": "offer_not_found", "message": "Offer not found"}, - ) - - now = datetime.utcnow() - if offer.claimed_at is not None: - raise HTTPException( - status.HTTP_409_CONFLICT, - detail={"code": "already_claimed", "message": "Offer already claimed"}, - ) - - if not offer.is_active or offer.expires_at <= now: - offer.is_active = False - await db.commit() - raise HTTPException( - status.HTTP_410_GONE, - detail={"code": "offer_expired", "message": "Offer expired"}, - ) - - effect_type = _normalize_effect_type(getattr(offer, "effect_type", None)) - - if effect_type == "test_access": - success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access( - db, - user, - offer, - ) - - if not success: - code = error_code or "claim_failed" - message_map = { - "subscription_missing": "Active subscription required", - "squads_missing": "No squads configured for test access", - "already_has_access": "Test access already granted", - "expired": "Offer expired", - } - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": code, "message": message_map.get(code, "Failed to grant test access")}, - ) - - offer.claimed_at = datetime.utcnow() - offer.is_active = False - await mark_offer_claimed(db, offer) - await db.commit() - - return MiniAppPromoOfferClaimResponse( - success=True, - effect_type=effect_type, - newly_added=bool(newly_added), - expires_at=expires_at, - ) - - if effect_type == "percent_discount": - success = await promo_offer_service.apply_discount_offer(db, user, offer) - if not success: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "claim_failed", "message": "Failed to apply discount"}, - ) - - offer.claimed_at = datetime.utcnow() - offer.is_active = False - await mark_offer_claimed(db, offer) - await db.commit() - - return MiniAppPromoOfferClaimResponse( - success=True, - effect_type=effect_type, - newly_added=False, - expires_at=offer.expires_at, - ) - - if effect_type == "balance_bonus": - success = await promo_offer_service.apply_balance_bonus(db, user, offer) - if not success: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "claim_failed", "message": "Failed to apply bonus"}, - ) - - offer.claimed_at = datetime.utcnow() - offer.is_active = False - await mark_offer_claimed(db, offer) - await db.commit() - - return MiniAppPromoOfferClaimResponse( - success=True, - effect_type=effect_type, - newly_added=False, - expires_at=offer.expires_at, - ) - - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "unsupported_effect", "message": "Unsupported offer type"}, - ) - - -__all__ = [ - "router", - "ActiveOfferContext", - "build_promo_offer_models", - "find_active_test_access_offers", - "extract_promo_discounts", - "resolve_connected_servers", -]