mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-26 22:31:44 +00:00
Merge pull request #955 from Fr1ngg/4ttjow-bedolaga/add-promo-offer-display-on-subscription-page
Add promo offer banners to mini app
This commit is contained in:
@@ -9,11 +9,7 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
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.discount_offer import get_offer_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
@@ -5242,35 +5238,13 @@ async def claim_discount_offer(
|
||||
)
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
if offer.claimed_at is not None:
|
||||
await callback.answer(
|
||||
texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
result = await promo_offer_service.claim_offer(db, db_user, offer)
|
||||
|
||||
if not offer.is_active or offer.expires_at <= now:
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
await callback.answer(
|
||||
texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
if not result.success:
|
||||
error_code = (result.error_code or "unknown").lower()
|
||||
effect_type = result.effect_type or "percent_discount"
|
||||
|
||||
effect_type = (offer.effect_type or "percent_discount").lower()
|
||||
if effect_type == "balance_bonus":
|
||||
effect_type = "percent_discount"
|
||||
|
||||
if effect_type == "test_access":
|
||||
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
|
||||
db,
|
||||
db_user,
|
||||
offer,
|
||||
)
|
||||
|
||||
if not success:
|
||||
if effect_type == "test_access":
|
||||
if error_code == "subscription_missing":
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_NO_SUBSCRIPTION",
|
||||
@@ -5296,19 +5270,35 @@ async def claim_discount_offer(
|
||||
"TEST_ACCESS_UNKNOWN_ERROR",
|
||||
"❌ Не удалось активировать предложение. Попробуйте позже.",
|
||||
)
|
||||
await callback.answer(error_message, show_alert=True)
|
||||
return
|
||||
else:
|
||||
if error_code == "already_claimed":
|
||||
error_message = texts.get(
|
||||
"DISCOUNT_CLAIM_ALREADY",
|
||||
"ℹ️ Скидка уже была активирована",
|
||||
)
|
||||
elif error_code in {"expired", "inactive"}:
|
||||
error_message = texts.get(
|
||||
"DISCOUNT_CLAIM_EXPIRED",
|
||||
"⚠️ Время действия предложения истекло",
|
||||
)
|
||||
elif error_code == "no_discount":
|
||||
error_message = texts.get(
|
||||
"DISCOUNT_CLAIM_ERROR",
|
||||
"❌ Не удалось активировать скидку. Попробуйте позже.",
|
||||
)
|
||||
else:
|
||||
error_message = texts.get(
|
||||
"DISCOUNT_CLAIM_ERROR",
|
||||
"❌ Не удалось активировать скидку. Попробуйте позже.",
|
||||
)
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
await callback.answer(error_message, show_alert=True)
|
||||
return
|
||||
|
||||
effect_type = (result.effect_type or "percent_discount").lower()
|
||||
|
||||
if effect_type == "test_access":
|
||||
expires_at = result.test_access_expires_at
|
||||
expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else ""
|
||||
success_message = texts.get(
|
||||
"TEST_ACCESS_ACTIVATED_MESSAGE",
|
||||
@@ -5330,52 +5320,11 @@ async def claim_discount_offer(
|
||||
await callback.message.answer(success_message, reply_markup=back_keyboard)
|
||||
return
|
||||
|
||||
discount_percent = int(offer.discount_percent or 0)
|
||||
if discount_percent <= 0:
|
||||
await callback.answer(
|
||||
texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось активировать скидку. Попробуйте позже."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
db_user.promo_offer_discount_percent = discount_percent
|
||||
db_user.promo_offer_discount_source = offer.notification_type
|
||||
db_user.updated_at = now
|
||||
|
||||
discount_percent = result.discount_percent
|
||||
discount_expires_at = result.discount_expires_at
|
||||
duration_hours = result.discount_duration_hours
|
||||
extra_data = offer.extra_data or {}
|
||||
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 (ValueError, TypeError):
|
||||
template = None
|
||||
if template and template.active_discount_hours:
|
||||
raw_duration = template.active_discount_hours
|
||||
|
||||
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
|
||||
|
||||
db_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(db_user)
|
||||
now = datetime.utcnow()
|
||||
|
||||
success_template = texts.get(
|
||||
"DISCOUNT_CLAIM_SUCCESS",
|
||||
@@ -5388,6 +5337,15 @@ async def claim_discount_offer(
|
||||
|
||||
format_values: Dict[str, Any] = {"percent": discount_percent}
|
||||
|
||||
if duration_hours is None and isinstance(extra_data, dict):
|
||||
raw_duration = extra_data.get("active_discount_hours") or extra_data.get("duration_hours")
|
||||
try:
|
||||
parsed_duration = int(raw_duration) if raw_duration is not None else None
|
||||
except (TypeError, ValueError):
|
||||
parsed_duration = None
|
||||
if parsed_duration and parsed_duration > 0:
|
||||
duration_hours = parsed_duration
|
||||
|
||||
if duration_hours and duration_hours > 0:
|
||||
format_values.setdefault("hours", duration_hours)
|
||||
format_values.setdefault("duration_hours", duration_hours)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
@@ -8,6 +9,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.discount_offer import mark_offer_claimed
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.models import (
|
||||
DiscountOffer,
|
||||
Subscription,
|
||||
@@ -20,6 +23,18 @@ from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromoOfferClaimOutcome:
|
||||
success: bool
|
||||
effect_type: str
|
||||
discount_percent: int = 0
|
||||
discount_expires_at: Optional[datetime] = None
|
||||
discount_duration_hours: Optional[int] = None
|
||||
test_access_expires_at: Optional[datetime] = None
|
||||
newly_added_squads: List[str] = field(default_factory=list)
|
||||
error_code: Optional[str] = None
|
||||
|
||||
|
||||
class PromoOfferService:
|
||||
def __init__(self) -> None:
|
||||
self.subscription_service = SubscriptionService()
|
||||
@@ -131,6 +146,118 @@ class PromoOfferService:
|
||||
|
||||
return True, newly_added, expires_at, "ok"
|
||||
|
||||
async def claim_offer(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
offer: DiscountOffer,
|
||||
) -> PromoOfferClaimOutcome:
|
||||
effect_type_raw = (offer.effect_type or "percent_discount").lower()
|
||||
effect_type = "percent_discount" if effect_type_raw == "balance_bonus" else effect_type_raw
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if offer.claimed_at is not None:
|
||||
return PromoOfferClaimOutcome(False, effect_type, error_code="already_claimed")
|
||||
|
||||
if not offer.is_active:
|
||||
return PromoOfferClaimOutcome(False, effect_type, error_code="inactive")
|
||||
|
||||
if offer.expires_at <= now:
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
return PromoOfferClaimOutcome(False, effect_type, error_code="expired")
|
||||
|
||||
if effect_type == "test_access":
|
||||
success, newly_added, expires_at, error_code = await self.grant_test_access(
|
||||
db,
|
||||
user,
|
||||
offer,
|
||||
)
|
||||
|
||||
if not success:
|
||||
return PromoOfferClaimOutcome(
|
||||
False,
|
||||
effect_type,
|
||||
error_code=error_code or "unknown",
|
||||
)
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
return PromoOfferClaimOutcome(
|
||||
True,
|
||||
effect_type,
|
||||
newly_added_squads=newly_added or [],
|
||||
test_access_expires_at=expires_at,
|
||||
)
|
||||
|
||||
try:
|
||||
discount_percent = int(offer.discount_percent or 0)
|
||||
except (TypeError, ValueError):
|
||||
discount_percent = 0
|
||||
|
||||
if discount_percent <= 0:
|
||||
return PromoOfferClaimOutcome(False, effect_type, error_code="no_discount")
|
||||
|
||||
user.promo_offer_discount_percent = discount_percent
|
||||
user.promo_offer_discount_source = offer.notification_type
|
||||
user.updated_at = now
|
||||
|
||||
extra_data = offer.extra_data or {}
|
||||
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
|
||||
|
||||
try:
|
||||
duration_hours = int(raw_duration) if raw_duration is not None else None
|
||||
except (TypeError, ValueError):
|
||||
duration_hours = None
|
||||
|
||||
discount_expires_at: Optional[datetime] = 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 PromoOfferClaimOutcome(
|
||||
True,
|
||||
effect_type,
|
||||
discount_percent=discount_percent,
|
||||
discount_expires_at=discount_expires_at,
|
||||
discount_duration_hours=duration_hours if duration_hours and duration_hours > 0 else None,
|
||||
)
|
||||
|
||||
async def cleanup_expired_test_access(self, db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -8,11 +11,14 @@ 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
|
||||
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.promo_group import get_auto_assign_promo_groups
|
||||
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
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.services.remnawave_service import (
|
||||
RemnaWaveConfigurationError,
|
||||
RemnaWaveService,
|
||||
@@ -29,7 +35,10 @@ from ..schemas.miniapp import (
|
||||
MiniAppConnectedServer,
|
||||
MiniAppDevice,
|
||||
MiniAppAutoPromoGroupLevel,
|
||||
MiniAppPromoOffer,
|
||||
MiniAppPromoGroup,
|
||||
MiniAppPromoOfferClaimRequest,
|
||||
MiniAppPromoOfferClaimResponse,
|
||||
MiniAppSubscriptionRequest,
|
||||
MiniAppSubscriptionResponse,
|
||||
MiniAppSubscriptionUser,
|
||||
@@ -218,6 +227,129 @@ def _is_remnawave_configured() -> bool:
|
||||
return bool(params.get("base_url") and params.get("api_key"))
|
||||
|
||||
|
||||
def _sanitize_offer_message(text: Optional[str]) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
|
||||
normalized = text.replace("\r\n", "\n")
|
||||
normalized = re.sub(r"<br\s*/?>", "\n", normalized, flags=re.IGNORECASE)
|
||||
stripped = re.sub(r"<[^>]+>", "", normalized)
|
||||
unescaped = html.unescape(stripped)
|
||||
cleaned = unescaped.strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _parse_init_payload(init_data: str) -> int:
|
||||
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:
|
||||
return int(telegram_user["id"])
|
||||
except (TypeError, ValueError) as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Telegram user identifier",
|
||||
) from error
|
||||
|
||||
|
||||
async def _load_available_promo_offers(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
) -> List[MiniAppPromoOffer]:
|
||||
offers = await list_discount_offers(
|
||||
db,
|
||||
user_id=user.id,
|
||||
is_active=True,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
templates_cache: Dict[int, Optional[Any]] = {}
|
||||
result: List[MiniAppPromoOffer] = []
|
||||
|
||||
for offer in offers:
|
||||
if offer.claimed_at is not None:
|
||||
continue
|
||||
if offer.expires_at <= now:
|
||||
continue
|
||||
|
||||
extra_data = offer.extra_data if isinstance(offer.extra_data, dict) else {}
|
||||
|
||||
template_id: Optional[int] = None
|
||||
raw_template_id = extra_data.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 in templates_cache:
|
||||
template = templates_cache[template_id]
|
||||
else:
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
templates_cache[template_id] = template
|
||||
|
||||
name = None
|
||||
button_text = None
|
||||
message_text = None
|
||||
|
||||
if template is not None:
|
||||
name = template.name
|
||||
button_text = template.button_text
|
||||
message_text = template.message_text
|
||||
else:
|
||||
name = extra_data.get("title") or extra_data.get("name")
|
||||
button_text = extra_data.get("button_text") or extra_data.get("buttonText")
|
||||
message_text = (
|
||||
extra_data.get("message_text")
|
||||
or extra_data.get("messageText")
|
||||
or extra_data.get("text")
|
||||
)
|
||||
|
||||
sanitized_message = _sanitize_offer_message(message_text)
|
||||
|
||||
try:
|
||||
discount_percent = int(offer.discount_percent or 0)
|
||||
except (TypeError, ValueError):
|
||||
discount_percent = 0
|
||||
|
||||
try:
|
||||
bonus_amount = int(offer.bonus_amount_kopeks or 0)
|
||||
except (TypeError, ValueError):
|
||||
bonus_amount = 0
|
||||
|
||||
result.append(
|
||||
MiniAppPromoOffer(
|
||||
id=offer.id,
|
||||
template_id=template_id,
|
||||
name=name,
|
||||
message=sanitized_message,
|
||||
button_text=button_text,
|
||||
discount_percent=discount_percent,
|
||||
bonus_amount_kopeks=bonus_amount,
|
||||
expires_at=offer.expires_at,
|
||||
effect_type=offer.effect_type or "percent_discount",
|
||||
extra_data=extra_data,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction:
|
||||
return MiniAppTransaction(
|
||||
id=transaction.id,
|
||||
@@ -266,28 +398,7 @@ async def get_subscription_details(
|
||||
payload: MiniAppSubscriptionRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionResponse:
|
||||
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
|
||||
telegram_id = _parse_init_payload(payload.init_data)
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip()
|
||||
@@ -361,6 +472,8 @@ async def get_subscription_details(
|
||||
)
|
||||
)
|
||||
|
||||
available_promo_offers = await _load_available_promo_offers(db, user)
|
||||
|
||||
response_user = MiniAppSubscriptionUser(
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
@@ -387,6 +500,14 @@ 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,
|
||||
),
|
||||
)
|
||||
|
||||
return MiniAppSubscriptionResponse(
|
||||
@@ -420,6 +541,7 @@ async def get_subscription_details(
|
||||
else None
|
||||
),
|
||||
auto_assign_promo_groups=auto_promo_levels,
|
||||
promo_offers=available_promo_offers,
|
||||
total_spent_kopeks=total_spent_kopeks,
|
||||
total_spent_rubles=round(total_spent_kopeks / 100, 2),
|
||||
total_spent_label=settings.format_price(total_spent_kopeks),
|
||||
@@ -428,6 +550,45 @@ async def get_subscription_details(
|
||||
branding=settings.get_miniapp_branding(),
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
telegram_id = _parse_init_payload(payload.init_data)
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "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, "Promo offer not found")
|
||||
|
||||
result = await promo_offer_service.claim_offer(db, user, offer)
|
||||
|
||||
if not result.success:
|
||||
return MiniAppPromoOfferClaimResponse(
|
||||
success=False,
|
||||
effect_type=result.effect_type,
|
||||
error_code=result.error_code or "unknown",
|
||||
)
|
||||
|
||||
return MiniAppPromoOfferClaimResponse(
|
||||
success=True,
|
||||
effect_type=result.effect_type,
|
||||
discount_percent=result.discount_percent or None,
|
||||
discount_expires_at=result.discount_expires_at,
|
||||
test_access_expires_at=result.test_access_expires_at,
|
||||
newly_added_squads=result.newly_added_squads,
|
||||
)
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
|
||||
@@ -34,6 +34,9 @@ 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 MiniAppPromoGroup(BaseModel):
|
||||
@@ -61,6 +64,19 @@ class MiniAppAutoPromoGroupLevel(BaseModel):
|
||||
apply_discounts_to_addons: bool = True
|
||||
|
||||
|
||||
class MiniAppPromoOffer(BaseModel):
|
||||
id: int
|
||||
template_id: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
button_text: Optional[str] = None
|
||||
discount_percent: int = 0
|
||||
bonus_amount_kopeks: int = 0
|
||||
expires_at: datetime
|
||||
effect_type: str = "percent_discount"
|
||||
extra_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MiniAppConnectedServer(BaseModel):
|
||||
uuid: str
|
||||
name: str
|
||||
@@ -111,6 +127,7 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
transactions: List[MiniAppTransaction] = Field(default_factory=list)
|
||||
promo_group: Optional[MiniAppPromoGroup] = None
|
||||
auto_assign_promo_groups: List[MiniAppAutoPromoGroupLevel] = Field(default_factory=list)
|
||||
promo_offers: List[MiniAppPromoOffer] = Field(default_factory=list)
|
||||
total_spent_kopeks: int = 0
|
||||
total_spent_rubles: float = 0.0
|
||||
total_spent_label: Optional[str] = None
|
||||
@@ -118,3 +135,17 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
autopay_enabled: bool = False
|
||||
branding: Optional[MiniAppBranding] = None
|
||||
|
||||
|
||||
class MiniAppPromoOfferClaimRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
|
||||
class MiniAppPromoOfferClaimResponse(BaseModel):
|
||||
success: bool = True
|
||||
effect_type: 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)
|
||||
error_code: Optional[str] = None
|
||||
|
||||
|
||||
1036
miniapp/index.html
1036
miniapp/index.html
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user