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:
Egor
2025-10-09 04:51:18 +03:00
committed by GitHub
5 changed files with 1358 additions and 175 deletions

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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

File diff suppressed because it is too large Load Diff