diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 7bb6f6eb..09a130d1 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple, Union from fastapi import APIRouter, Depends, HTTPException, status @@ -9,14 +8,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.discount_offer import ( - get_offer_by_id, - list_discount_offers, - mark_offer_claimed, -) from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.crud.promo_group import get_auto_assign_promo_groups -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id from app.database.crud.transaction import get_user_total_spent_kopeks from app.database.crud.user import get_user_by_telegram_id from app.database.models import PromoGroup, Subscription, Transaction, User @@ -24,7 +17,6 @@ from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, ) -from app.services.promo_offer_service import promo_offer_service from app.services.subscription_service import SubscriptionService from app.utils.subscription_utils import get_happ_cryptolink_redirect_link from app.utils.telegram_webapp import ( @@ -37,9 +29,6 @@ from ..schemas.miniapp import ( MiniAppConnectedServer, MiniAppDevice, MiniAppAutoPromoGroupLevel, - MiniAppPromoOffer, - MiniAppPromoOfferClaimRequest, - MiniAppPromoOfferClaimResponse, MiniAppPromoGroup, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, @@ -153,53 +142,6 @@ async def _resolve_connected_servers( return connected_servers -async def _resolve_authorized_user( - db: AsyncSession, - init_data: str, - *, - require_subscription: bool = False, - include_purchase_url: bool = False, -) -> User: - try: - webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN) - except TelegramWebAppAuthError as error: - raise HTTPException( - status_code=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_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid Telegram user payload", - ) - - try: - telegram_id = int(telegram_user["id"]) - except (TypeError, ValueError): - raise HTTPException( - status_code=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 or (require_subscription and not getattr(user, "subscription", None)): - detail: Union[str, Dict[str, str]] = ( - "Subscription not found" if require_subscription else "User not found" - ) - if include_purchase_url and require_subscription: - purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip() - if purchase_url: - detail = { - "message": "Subscription not found", - "purchase_url": purchase_url, - } - raise HTTPException(status.HTTP_404_NOT_FOUND, detail=detail) - - return user - - async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: remnawave_uuid = getattr(user, "remnawave_uuid", None) if not remnawave_uuid: @@ -324,15 +266,44 @@ async def get_subscription_details( payload: MiniAppSubscriptionRequest, db: AsyncSession = Depends(get_db_session), ) -> MiniAppSubscriptionResponse: - user = await _resolve_authorized_user( - db, - payload.init_data, - require_subscription=True, - include_purchase_url=True, - ) + try: + webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) + except TelegramWebAppAuthError as error: + raise HTTPException( + status_code=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_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user payload", + ) + + try: + telegram_id = int(telegram_user["id"]) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user identifier", + ) from None + + user = await get_user_by_telegram_id(db, telegram_id) + purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip() + if not user or not user.subscription: + detail: Union[str, Dict[str, str]] = "Subscription not found" + if purchase_url: + detail = { + "message": "Subscription not found", + "purchase_url": purchase_url, + } + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=detail, + ) subscription = user.subscription - purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip() traffic_used = _format_gb(subscription.traffic_used_gb) traffic_limit = subscription.traffic_limit_gb or 0 lifetime_used = _bytes_to_gb(getattr(user, "lifetime_used_traffic_bytes", 0)) @@ -416,102 +387,8 @@ async def get_subscription_details( traffic_limit_label=_format_limit_label(traffic_limit), lifetime_used_traffic_gb=lifetime_used, has_active_subscription=status_actual in {"active", "trial"}, - promo_offer_discount_percent=int( - getattr(user, "promo_offer_discount_percent", 0) or 0 - ), - promo_offer_discount_source=getattr(user, "promo_offer_discount_source", None), - promo_offer_discount_expires_at=getattr( - user, - "promo_offer_discount_expires_at", - None, - ), ) - raw_offers = await list_discount_offers(db, user_id=user.id, is_active=True) - now = datetime.utcnow() - template_cache: Dict[int, Optional[Any]] = {} - promo_offers: List[MiniAppPromoOffer] = [] - - for offer in raw_offers: - expires_at = getattr(offer, "expires_at", None) - if not expires_at or expires_at <= now: - continue - if getattr(offer, "claimed_at", None): - continue - - extra_data_raw = getattr(offer, "extra_data", {}) - normalized_extra: Dict[str, Any] - if isinstance(extra_data_raw, dict): - normalized_extra = dict(extra_data_raw) - else: - normalized_extra = {} - - offer_type = normalized_extra.get("offer_type") - button_text = normalized_extra.get("button_text") - message_text = normalized_extra.get("message_text") - - template_id: Optional[int] = None - raw_template_id = normalized_extra.get("template_id") - if raw_template_id is not None: - try: - template_id = int(raw_template_id) - except (TypeError, ValueError): - template_id = None - - template = None - if template_id is not None: - if template_id not in template_cache: - template_cache[template_id] = await get_promo_offer_template_by_id( - db, - template_id, - ) - template = template_cache.get(template_id) - - active_hours_raw = normalized_extra.get("active_discount_hours") - if template: - offer_type = offer_type or template.offer_type - button_text = button_text or template.button_text - message_text = message_text or template.message_text - if (not active_hours_raw) and template.active_discount_hours: - active_hours_raw = template.active_discount_hours - - try: - active_hours = int(float(active_hours_raw)) if active_hours_raw is not None else None - except (TypeError, ValueError): - active_hours = None - - if template_id is not None: - normalized_extra.setdefault("template_id", template_id) - if offer_type: - normalized_extra["offer_type"] = offer_type - if button_text: - normalized_extra["button_text"] = button_text - if message_text: - normalized_extra["message_text"] = message_text - if active_hours is not None: - normalized_extra["active_discount_hours"] = active_hours - - promo_offers.append( - MiniAppPromoOffer( - id=offer.id, - notification_type=str(getattr(offer, "notification_type", "")), - discount_percent=int(getattr(offer, "discount_percent", 0) or 0), - bonus_amount_kopeks=int(getattr(offer, "bonus_amount_kopeks", 0) or 0), - expires_at=expires_at, - created_at=getattr(offer, "created_at", now), - updated_at=getattr(offer, "updated_at", now), - effect_type=getattr(offer, "effect_type", "percent_discount") or "percent_discount", - is_active=bool(getattr(offer, "is_active", True)), - offer_type=offer_type, - button_text=button_text, - message_text=message_text, - active_discount_hours=active_hours, - extra_data=normalized_extra, - ) - ) - - promo_offers.sort(key=lambda item: item.expires_at) - return MiniAppSubscriptionResponse( subscription_id=subscription.id, remnawave_short_uuid=subscription.remnawave_short_uuid, @@ -549,132 +426,6 @@ async def get_subscription_details( subscription_type="trial" if subscription.is_trial else "paid", autopay_enabled=bool(subscription.autopay_enabled), branding=settings.get_miniapp_branding(), - promo_offers=promo_offers, - ) - - -@router.post( - "/promo-offers/{offer_id}/claim", - response_model=MiniAppPromoOfferClaimResponse, -) -async def claim_miniapp_promo_offer( - offer_id: int, - payload: MiniAppPromoOfferClaimRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppPromoOfferClaimResponse: - user = await _resolve_authorized_user(db, payload.init_data) - - 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="Offer not found") - - now = datetime.utcnow() - if getattr(offer, "claimed_at", None) is not None: - return MiniAppPromoOfferClaimResponse( - success=False, - status="already_claimed", - message="Offer already claimed", - ) - - expires_at = getattr(offer, "expires_at", None) - if not offer.is_active or (expires_at and expires_at <= now): - offer.is_active = False - await db.commit() - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Offer expired") - - effect_type = (getattr(offer, "effect_type", "percent_discount") or "percent_discount").lower() - extra_data = offer.extra_data if isinstance(offer.extra_data, dict) else {} - - if effect_type == "test_access": - success, newly_added, access_expires_at, error_code = await promo_offer_service.grant_test_access( - db, - user, - offer, - ) - if not success: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={ - "code": error_code or "test_access_failed", - "message": "Unable to grant test access", - }, - ) - - await mark_offer_claimed( - db, - offer, - details={ - "context": "test_access_claim", - "new_squads": newly_added, - "expires_at": access_expires_at.isoformat() if access_expires_at else None, - }, - ) - - return MiniAppPromoOfferClaimResponse( - success=True, - status="test_access_granted", - message="Test access activated", - test_access_expires_at=access_expires_at, - newly_added_squads=newly_added or [], - ) - - if effect_type == "balance_bonus": - effect_type = "percent_discount" - - discount_percent = int(getattr(offer, "discount_percent", 0) or 0) - if discount_percent <= 0: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Discount percent is not available", - ) - - user.promo_offer_discount_percent = discount_percent - user.promo_offer_discount_source = getattr(offer, "notification_type", None) - user.updated_at = now - - raw_duration = extra_data.get("active_discount_hours") if isinstance(extra_data, dict) else None - template_id = None - if isinstance(extra_data, dict): - template_id = extra_data.get("template_id") - - if (raw_duration in (None, "", 0, "0")) and template_id is not None: - 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 - - try: - duration_hours = int(float(raw_duration)) if raw_duration is not None else None - except (TypeError, ValueError): - duration_hours = None - - discount_expires_at = None - if duration_hours and duration_hours > 0: - discount_expires_at = now + timedelta(hours=duration_hours) - - 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, - status="discount_claimed", - message="Discount activated", - discount_percent=discount_percent, - discount_expires_at=discount_expires_at, ) def _safe_int(value: Any) -> int: diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 26efaf83..360e6fa5 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -34,26 +34,6 @@ class MiniAppSubscriptionUser(BaseModel): traffic_limit_label: str lifetime_used_traffic_gb: float = 0.0 has_active_subscription: bool = False - promo_offer_discount_percent: int = 0 - promo_offer_discount_source: Optional[str] = None - promo_offer_discount_expires_at: Optional[datetime] = None - - -class MiniAppPromoOffer(BaseModel): - id: int - notification_type: str - discount_percent: int = 0 - bonus_amount_kopeks: int = 0 - expires_at: datetime - created_at: datetime - updated_at: datetime - effect_type: str - is_active: bool = True - offer_type: Optional[str] = None - button_text: Optional[str] = None - message_text: Optional[str] = None - active_discount_hours: Optional[int] = None - extra_data: Dict[str, Any] = Field(default_factory=dict) class MiniAppPromoGroup(BaseModel): @@ -137,19 +117,4 @@ class MiniAppSubscriptionResponse(BaseModel): subscription_type: str autopay_enabled: bool = False branding: Optional[MiniAppBranding] = None - promo_offers: List[MiniAppPromoOffer] = Field(default_factory=list) - - -class MiniAppPromoOfferClaimRequest(BaseModel): - init_data: str = Field(..., alias="initData") - - -class MiniAppPromoOfferClaimResponse(BaseModel): - success: bool = True - status: str - message: Optional[str] = None - discount_percent: Optional[int] = None - discount_expires_at: Optional[datetime] = None - test_access_expires_at: Optional[datetime] = None - newly_added_squads: List[str] = Field(default_factory=list) diff --git a/miniapp/index.html b/miniapp/index.html index 5ce58abc..7161ee85 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -397,168 +397,6 @@ padding: 0 16px 16px; } - /* Promo Offer Banner */ - .promo-offer-container { - display: flex; - flex-direction: column; - gap: 16px; - margin-bottom: 20px; - } - - .promo-offer-card { - position: relative; - padding: 18px 20px; - border-radius: var(--radius-lg); - background: var(--bg-secondary); - border: 1px solid var(--border-color); - box-shadow: var(--shadow-sm); - display: flex; - flex-direction: column; - gap: 16px; - transition: transform 0.3s ease, box-shadow 0.3s ease; - } - - .promo-offer-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); - } - - .promo-offer-card-active { - background: linear-gradient(140deg, rgba(var(--primary-rgb), 0.18), rgba(var(--primary-rgb), 0.42)); - color: var(--text-primary); - border-color: rgba(var(--primary-rgb), 0.4); - box-shadow: var(--shadow-md); - } - - .promo-offer-card-header { - display: flex; - align-items: center; - gap: 14px; - } - - .promo-offer-icon { - width: 42px; - height: 42px; - border-radius: 14px; - background: rgba(var(--primary-rgb), 0.12); - display: flex; - align-items: center; - justify-content: center; - font-size: 22px; - } - - .promo-offer-card-active .promo-offer-icon { - background: rgba(255, 255, 255, 0.2); - color: #fff; - } - - .promo-offer-header-text { - display: flex; - flex-direction: column; - gap: 4px; - } - - .promo-offer-title { - font-size: 18px; - font-weight: 700; - color: var(--text-primary); - } - - .promo-offer-subtitle { - font-size: 14px; - color: var(--text-secondary); - } - - .promo-offer-card-active .promo-offer-subtitle { - color: rgba(255, 255, 255, 0.85); - } - - .promo-offer-card-body { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 16px; - } - - .promo-offer-highlight { - font-size: 32px; - font-weight: 800; - letter-spacing: 0.5px; - color: var(--primary); - } - - .promo-offer-card-active .promo-offer-highlight { - color: var(--tg-theme-button-text-color); - background: rgba(255, 255, 255, 0.16); - padding: 6px 14px; - border-radius: var(--radius); - } - - .promo-offer-badges { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - .promo-offer-badge { - display: inline-flex; - align-items: center; - padding: 6px 12px; - border-radius: var(--radius); - background: rgba(var(--primary-rgb), 0.12); - color: var(--primary); - font-weight: 600; - font-size: 13px; - } - - .promo-offer-card-active .promo-offer-badge { - background: rgba(255, 255, 255, 0.18); - color: #fff; - } - - .promo-offer-card-footer { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 16px; - } - - .promo-offer-timer { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 13px; - } - - .promo-offer-timer-label { - text-transform: uppercase; - letter-spacing: 0.4px; - color: var(--text-secondary); - font-weight: 600; - } - - .promo-offer-timer-value { - font-size: 16px; - font-weight: 700; - color: var(--text-primary); - } - - .promo-offer-card-active .promo-offer-timer-label, - .promo-offer-card-active .promo-offer-timer-value { - color: rgba(255, 255, 255, 0.9); - } - - .promo-offer-action { - min-width: 160px; - white-space: nowrap; - } - - .promo-offer-action:disabled { - opacity: 0.7; - } - /* User Card Specific */ .user-card { background: linear-gradient(135deg, var(--bg-secondary), rgba(var(--primary-rgb), 0.05)); @@ -1699,26 +1537,6 @@ color: var(--primary); } - :root[data-theme="dark"] .promo-offer-card { - background: rgba(30, 41, 59, 0.78); - border-color: rgba(148, 163, 184, 0.28); - box-shadow: var(--shadow-md); - } - - :root[data-theme="dark"] .promo-offer-card-active { - background: linear-gradient(145deg, rgba(var(--primary-rgb), 0.25), rgba(var(--primary-rgb), 0.55)); - border-color: rgba(var(--primary-rgb), 0.45); - } - - :root[data-theme="dark"] .promo-offer-card-active .promo-offer-highlight { - background: rgba(15, 23, 42, 0.35); - } - - :root[data-theme="dark"] .promo-offer-badge { - background: rgba(var(--primary-rgb), 0.22); - color: #fff; - } - :root[data-theme="dark"] body, :root[data-theme="dark"] .card, :root[data-theme="dark"] .user-card, @@ -1778,8 +1596,6 @@ -
-