RemnaWave-bot Update

This commit is contained in:
2026-05-06 15:41:56 +03:00
parent ce7a1f70b2
commit 2cc2f12a21
22 changed files with 3087 additions and 129 deletions

View File

@@ -4,7 +4,7 @@ from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import PaymentPlanSettings, Settings
from app.config import PAYMENT_PRODUCT_PLAN_SPECS, PaymentPlanSettings, Settings
from app.db.base import utcnow
from app.db.models import BotSetting
@@ -34,11 +34,14 @@ class BotConfigSnapshot:
referral_discount_percent: int
referral_bonus_days: int
payment_plans_raw: str
payment_product_plans_raw: str
payment_transfer_text: str
payment_support_text: str
payment_plan_traffic_limit_gb: int
payment_plan_traffic_reset_period: str
payment_internal_squad_uuids_raw: str
payment_vpn_squad_uuids_raw: str
payment_white_squad_uuids_raw: str
payment_external_squad_uuid: str
payment_username_prefix: str
payment_user_tag: str
@@ -75,12 +78,15 @@ class BotConfigSnapshot:
@property
def payment_internal_squad_uuids(self) -> list[str]:
values: list[str] = []
for raw_part in self.payment_internal_squad_uuids_raw.split(","):
part = raw_part.strip()
if part:
values.append(part)
return values
return self._parse_csv_list(self.payment_internal_squad_uuids_raw)
@property
def payment_vpn_squad_uuids(self) -> list[str]:
return self._parse_csv_list(self.payment_vpn_squad_uuids_raw)
@property
def payment_white_squad_uuids(self) -> list[str]:
return self._parse_csv_list(self.payment_white_squad_uuids_raw)
@property
def notification_days_list(self) -> list[int]:
@@ -95,6 +101,13 @@ class BotConfigSnapshot:
@property
def payment_plans(self) -> list[PaymentPlanSettings]:
product_plans = self._parse_product_plans(
self.payment_product_plans_raw,
default_days=30,
)
if product_plans:
return product_plans
values: list[PaymentPlanSettings] = []
for raw_part in self.payment_plans_raw.split(","):
part = raw_part.strip()
@@ -120,6 +133,80 @@ class BotConfigSnapshot:
return sorted(values, key=lambda item: item.days)
@staticmethod
def _parse_csv_list(raw_value: str) -> list[str]:
values: list[str] = []
for raw_part in raw_value.split(","):
part = raw_part.strip()
if part:
values.append(part)
return values
@staticmethod
def _parse_product_plans(
raw_value: str,
*,
default_days: int,
) -> list[PaymentPlanSettings]:
values: list[PaymentPlanSettings] = []
seen_codes: set[str] = set()
for raw_part in raw_value.split(","):
part = raw_part.strip()
if not part:
continue
fragments = [fragment.strip() for fragment in part.split(":")]
# Legacy format: code:amount (2 fields)
if len(fragments) == 2:
code, raw_amount = fragments
pairs = [(str(default_days), raw_amount)]
# Standard format: code:days:amount (3 fields)
elif len(fragments) == 3:
code = fragments[0]
pairs = [(fragments[1], fragments[2])]
# Extended format: code:days1:amount1:days2:amount2:... (odd ≥ 5)
elif len(fragments) >= 5 and len(fragments) % 2 == 1:
code = fragments[0]
pairs = [
(fragments[i], fragments[i + 1])
for i in range(1, len(fragments), 2)
]
else:
continue
normalized_code = code.lower()
spec = PAYMENT_PRODUCT_PLAN_SPECS.get(normalized_code)
if spec is None or normalized_code in seen_codes:
continue
title, description, squad_groups = spec
for raw_days, raw_amount in pairs:
if not raw_days.isdigit() or not raw_amount.isdigit():
continue
days = int(raw_days)
amount_rub = int(raw_amount)
if days <= 0 or amount_rub <= 0:
continue
plan_code = f"{normalized_code}_{days}"
values.append(
PaymentPlanSettings(
code=plan_code,
days=days,
amount_rub=amount_rub,
title=title,
description=description,
squad_groups=squad_groups,
)
)
seen_codes.add(normalized_code)
return values
@staticmethod
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
normalized = " ".join(raw_value.split()).strip()
@@ -176,6 +263,7 @@ class StaticBotConfigService:
referral_discount_percent=self._settings.referral_discount_percent,
referral_bonus_days=self._settings.referral_bonus_days,
payment_plans_raw=self._settings.payment_plans_raw,
payment_product_plans_raw=self._settings.payment_product_plans_raw,
payment_transfer_text=self._settings.payment_transfer_text,
payment_support_text=self._settings.payment_support_text,
payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb,
@@ -184,6 +272,8 @@ class StaticBotConfigService:
notification_days_before="1,3",
notification_check_interval_hours=6,
payment_internal_squad_uuids_raw=self._settings.payment_internal_squad_uuids_raw,
payment_vpn_squad_uuids_raw=self._settings.payment_vpn_squad_uuids_raw,
payment_white_squad_uuids_raw=self._settings.payment_white_squad_uuids_raw,
payment_external_squad_uuid=self._settings.payment_external_squad_uuid,
payment_username_prefix=self._settings.payment_username_prefix,
payment_user_tag=self._settings.payment_user_tag,
@@ -233,9 +323,24 @@ class BotConfigService(StaticBotConfigService):
label="Тарифы и цены",
section="pricing",
prompt="Введите тарифы в формате `30:250,180:600,365:1000`.",
description="Каждая пара это `дни:цена_в_рублях`.",
description="Каждая пара - это `дни:цена_в_рублях`.",
placeholder="30:250,180:600,365:1000",
),
"payment_product_plans_raw": BotConfigFieldSpec(
key="payment_product_plans_raw",
label="Типы подписок и цены",
section="pricing",
prompt=(
"Введите типы подписок в формате "
"`vpn_white:30:450:180:2200:365:4000,white:30:250,vpn:30:200`.\n"
"Каждый продукт: `код:дни1:цена1:дни2:цена2:...`"
),
description=(
"Если заполнено, заменяет обычные PAYMENT_PLANS. "
"Коды: vpn_white, white, vpn. Формат: `код:дни1:цена1[:дни2:цена2:...]`."
),
placeholder="vpn_white:30:450:180:2200:365:4000,white:30:250:180:1200,vpn:30:200:180:1000",
),
"payment_plan_traffic_limit_gb": BotConfigFieldSpec(
key="payment_plan_traffic_limit_gb",
label="Лимит трафика, GB",
@@ -332,6 +437,22 @@ class BotConfigService(StaticBotConfigService):
description="Эти группы назначаются пользователю в Remnawave.",
placeholder="11111111-1111-1111-1111-111111111111",
),
"payment_vpn_squad_uuids_raw": BotConfigFieldSpec(
key="payment_vpn_squad_uuids_raw",
label="UUID VPN-групп",
section="provisioning",
prompt="Введите UUID squad для тарифа VPN через запятую.",
description="Назначается тарифам `vpn` и `vpn_white`.",
placeholder="11111111-1111-1111-1111-111111111111",
),
"payment_white_squad_uuids_raw": BotConfigFieldSpec(
key="payment_white_squad_uuids_raw",
label="UUID групп белых списков",
section="provisioning",
prompt="Введите UUID squad для белых списков через запятую.",
description="Назначается тарифам `white` и `vpn_white`.",
placeholder="22222222-2222-2222-2222-222222222222",
),
"payment_external_squad_uuid": BotConfigFieldSpec(
key="payment_external_squad_uuid",
label="UUID внешней группы",
@@ -429,6 +550,12 @@ class BotConfigService(StaticBotConfigService):
default=self._settings.referral_bonus_days,
),
payment_plans_raw=str(overrides.get("payment_plans_raw", self._settings.payment_plans_raw)),
payment_product_plans_raw=str(
overrides.get(
"payment_product_plans_raw",
self._settings.payment_product_plans_raw,
)
),
payment_transfer_text=str(overrides.get("payment_transfer_text", self._settings.payment_transfer_text)),
payment_support_text=str(overrides.get("payment_support_text", self._settings.payment_support_text)),
payment_plan_traffic_limit_gb=self._parse_int(
@@ -447,6 +574,18 @@ class BotConfigService(StaticBotConfigService):
self._settings.payment_internal_squad_uuids_raw,
)
),
payment_vpn_squad_uuids_raw=str(
overrides.get(
"payment_vpn_squad_uuids_raw",
self._settings.payment_vpn_squad_uuids_raw,
)
),
payment_white_squad_uuids_raw=str(
overrides.get(
"payment_white_squad_uuids_raw",
self._settings.payment_white_squad_uuids_raw,
)
),
payment_external_squad_uuid=str(
overrides.get(
"payment_external_squad_uuid",
@@ -533,11 +672,17 @@ class BotConfigService(StaticBotConfigService):
"bot_support_ticket_link",
"payment_review_link",
"bot_start_image_path",
"payment_product_plans_raw",
"payment_vpn_squad_uuids_raw",
"payment_white_squad_uuids_raw",
"payment_external_squad_uuid",
"payment_user_tag",
"payment_username_prefix",
"payment_internal_squad_uuids_raw",
"payment_vpn_squad_uuids_raw",
"payment_white_squad_uuids_raw",
"payment_plans_raw",
"payment_product_plans_raw",
"payment_plan_traffic_reset_period",
"bot_brand_name",
"notification_days_before",
@@ -611,6 +756,20 @@ class BotConfigService(StaticBotConfigService):
plans = self._parse_payment_plans(value)
return ",".join(f"{plan.days}:{plan.amount_rub}" for plan in plans)
if key == "payment_product_plans_raw":
plans = self._parse_product_plans(value)
# Re-serialize: group plans by product code prefix
product_groups: dict[str, list[PaymentPlanSettings]] = {}
for plan in plans:
# Extract product code from plan code (e.g. "vpn_white" from "vpn_white_30")
product_code = "_".join(plan.code.split("_")[:-1]) if "_" in plan.code and plan.code.split("_")[-1].isdigit() else plan.code
product_groups.setdefault(product_code, []).append(plan)
parts = []
for product_code, group_plans in product_groups.items():
pairs = ":".join(f"{p.days}:{p.amount_rub}" for p in group_plans)
parts.append(f"{product_code}:{pairs}")
return ",".join(parts)
if key == "payment_plan_traffic_limit_gb":
amount = self._parse_non_negative_int(value, "Лимит трафика")
return str(amount)
@@ -652,6 +811,12 @@ class BotConfigService(StaticBotConfigService):
raise ValueError("Нужно указать хотя бы один UUID внутренней группы.")
return ",".join(normalized_parts)
if key in {"payment_vpn_squad_uuids_raw", "payment_white_squad_uuids_raw"}:
normalized_parts = [part.strip() for part in value.split(",") if part.strip()]
if not normalized_parts:
raise ValueError("Нужно указать хотя бы один UUID группы.")
return ",".join(normalized_parts)
if key == "payment_external_squad_uuid":
return value
@@ -711,6 +876,75 @@ class BotConfigService(StaticBotConfigService):
raise ValueError(f"{label} должен быть целым неотрицательным числом.")
return int(raw_value.strip())
@staticmethod
def _parse_product_plans(raw_value: str) -> list[PaymentPlanSettings]:
values: list[PaymentPlanSettings] = []
seen_codes: set[str] = set()
for raw_part in raw_value.split(","):
part = raw_part.strip()
if not part:
continue
fragments = [fragment.strip() for fragment in part.split(":")]
# Legacy: code:amount (2 fields)
if len(fragments) == 2:
code, raw_amount = fragments
pairs = [("30", raw_amount)]
# Standard: code:days:amount (3 fields)
elif len(fragments) == 3:
code = fragments[0]
pairs = [(fragments[1], fragments[2])]
# Extended: code:days1:amount1:days2:amount2:... (odd ≥ 5)
elif len(fragments) >= 5 and len(fragments) % 2 == 1:
code = fragments[0]
pairs = [
(fragments[i], fragments[i + 1])
for i in range(1, len(fragments), 2)
]
else:
raise ValueError(
"Типы подписок: `код:дни1:цена1[:дни2:цена2:...]`, "
"например `vpn_white:30:450:180:2200,white:30:250,vpn:30:200`."
)
normalized_code = code.lower()
spec = PAYMENT_PRODUCT_PLAN_SPECS.get(normalized_code)
if spec is None:
raise ValueError("Неизвестный код подписки. Доступны: vpn_white, white, vpn.")
if normalized_code in seen_codes:
raise ValueError("Коды подписок не должны повторяться.")
title, description, squad_groups = spec
for raw_days, raw_amount in pairs:
if not raw_days.isdigit() or not raw_amount.isdigit():
raise ValueError("Дни и цена должны быть целыми положительными числами.")
days = int(raw_days)
amount_rub = int(raw_amount)
if days <= 0 or amount_rub <= 0:
raise ValueError("У подписок дни и цена должны быть больше нуля.")
plan_code = f"{normalized_code}_{days}"
values.append(
PaymentPlanSettings(
code=plan_code,
days=days,
amount_rub=amount_rub,
title=title,
description=description,
squad_groups=squad_groups,
)
)
seen_codes.add(normalized_code)
if not values:
raise ValueError("Нужно указать хотя бы один тип подписки.")
return values
@staticmethod
def _parse_payment_plans(raw_value: str) -> list[PaymentPlanSettings]:
values: list[PaymentPlanSettings] = []

View File

@@ -31,9 +31,31 @@ REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class AppliedDiscount:
code: str = ""
source: str = ""
discount_percent: int = 0
plan_code: str | None = None
expires_at: datetime | None = None
def applies_to_plan(self, plan_code: str) -> bool:
if not self.code:
return False
if self.plan_code is None:
return True
# "all_30" matches any product with 30 days (vpn_white_30, white_30, vpn_30)
if self.plan_code.startswith("all_"):
suffix = self.plan_code.removeprefix("all") # "_30"
return plan_code.endswith(suffix)
# Exact match or product-prefix match (e.g. promo plan_code="vpn_white" matches plan "vpn_white_30")
return self.plan_code == plan_code or plan_code.startswith(f"{self.plan_code}_")
@dataclass(slots=True)
class PaymentPlan:
code: str
label: str
title: str
description: str
duration_days: int
@@ -41,6 +63,9 @@ class PaymentPlan:
discount_percent: int
original_amount_rub: int
applied_referral_code: str = ""
discount_code: str = ""
discount_source: str = ""
discount_scope_plan_code: str | None = None
traffic_limit_bytes: int = 0
traffic_limit_strategy: str = "NO_RESET"
internal_squad_uuids: list[str] | None = None
@@ -130,14 +155,14 @@ class PaymentService:
async def get_available_plans(self, *, telegram_id: int) -> list[PaymentPlan]:
config = await self._get_config()
applied_referral_code = await self._get_applied_referral_code(
applied_discount = await self._get_applied_discount(
telegram_id,
config=config,
)
return [
self._build_plan(
plan,
applied_referral_code=applied_referral_code,
applied_discount=applied_discount,
config=config,
)
for plan in config.payment_plans
@@ -154,13 +179,13 @@ class PaymentService:
plan_code: str,
) -> CreatedPaymentOrder:
config = await self._get_config()
applied_referral_code = await self._get_applied_referral_code(
applied_discount = await self._get_applied_discount(
telegram_id,
config=config,
)
plan = self._get_plan_by_code(
plan_code,
applied_referral_code=applied_referral_code,
applied_discount=applied_discount,
config=config,
)
if not plan.is_ready:
@@ -203,7 +228,7 @@ class PaymentService:
status=PENDING_PAYMENT_STATUS,
invoice_payload=invoice_payload,
provision_username=provision_username,
error_message=applied_referral_code or None,
error_message=plan.discount_code or None,
)
)
await session.commit()
@@ -293,10 +318,15 @@ class PaymentService:
async def approve_order(self, *, order_uuid: str) -> IssuedAccess:
order = await self._claim_order_for_approval(order_uuid)
config = await self._get_config()
is_renewal = False
try:
config = await self._get_config()
plan = self._get_plan_by_code(
order.plan_code,
applied_discount=AppliedDiscount(),
config=config,
)
remote_user: RemnawaveUser | None = None
if order.remnawave_user_uuid:
try:
@@ -324,19 +354,39 @@ class PaymentService:
remote_user = self._select_referral_bonus_target(remote_users)
if remote_user is None:
remote_user = await self._create_remnawave_user(order, config=config)
remote_user = await self._create_remnawave_user(
order,
plan=plan,
config=config,
)
else:
is_renewal = True
remote_user = await self._extend_remnawave_user(
order,
remote_user,
plan=plan,
config=config,
)
except ValueError as exc:
await self._restore_review_after_failed_approval(order.order_uuid, str(exc))
raise
except RemnawaveApiError as exc:
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
raise
await self._save_fulfilled_order(order.order_uuid, remote_user)
# Burn the applied discount (promo or referral) so it cannot be reused
try:
await self._sync_service.consume_discount_for_user(order.telegram_id)
except Exception:
logger.warning(
"Could not consume discount for telegram_id=%s after order=%s",
order.telegram_id,
order.order_uuid,
exc_info=True,
)
own_bonus = None
try:
own_bonus = await self.apply_pending_referral_bonuses(
@@ -422,9 +472,14 @@ class PaymentService:
"",
f"Сумма к переводу: <b>{order.plan.amount_rub} ₽</b>",
]
if order.plan.applied_referral_code:
if order.plan.discount_code:
discount_label = (
"Промокод"
if order.plan.discount_source == "promo"
else "Реферальный код"
)
lines.append(
f"Реферальный код: <code>{order.plan.applied_referral_code}</code> "
f"{discount_label}: <code>{order.plan.discount_code}</code> "
f"(<b>-{order.plan.discount_percent}%</b>)"
)
if order.extends_existing_access:
@@ -458,14 +513,14 @@ class PaymentService:
self,
plan_code: str,
*,
applied_referral_code: str,
applied_discount: AppliedDiscount,
config: BotConfigSnapshot,
) -> PaymentPlan:
for plan in config.payment_plans:
if plan.code == plan_code:
return self._build_plan(
plan,
applied_referral_code=applied_referral_code,
applied_discount=applied_discount,
config=config,
)
@@ -475,10 +530,15 @@ class PaymentService:
self,
plan_settings: PaymentPlanSettings,
*,
applied_referral_code: str,
applied_discount: AppliedDiscount,
config: BotConfigSnapshot,
) -> PaymentPlan:
discount_percent = max(config.referral_discount_percent, 0) if applied_referral_code else 0
resolved_discount = (
applied_discount
if applied_discount.applies_to_plan(plan_settings.code)
else AppliedDiscount()
)
discount_percent = max(resolved_discount.discount_percent, 0)
discounted_amount = self._apply_discount(
amount_rub=plan_settings.amount_rub,
discount_percent=discount_percent,
@@ -486,36 +546,105 @@ class PaymentService:
traffic_limit_bytes = max(config.payment_plan_traffic_limit_gb, 0) * 1024**3
return PaymentPlan(
code=plan_settings.code,
title=f"{config.bot_brand_name} на {plan_settings.days} дней",
description=f"Доступ к VPN на {plan_settings.days} дней",
label=plan_settings.title or f"{plan_settings.days} дней",
title=(
f"{config.bot_brand_name} · {plan_settings.title}"
if plan_settings.title
else f"{config.bot_brand_name} на {plan_settings.days} дней"
),
description=plan_settings.description or f"Доступ к VPN на {plan_settings.days} дней",
duration_days=plan_settings.days,
amount_rub=discounted_amount,
discount_percent=discount_percent,
original_amount_rub=plan_settings.amount_rub,
applied_referral_code=applied_referral_code,
applied_referral_code=(
resolved_discount.code if resolved_discount.source == "referral" else ""
),
discount_code=resolved_discount.code,
discount_source=resolved_discount.source,
discount_scope_plan_code=resolved_discount.plan_code,
traffic_limit_bytes=traffic_limit_bytes,
traffic_limit_strategy=config.payment_plan_traffic_reset_period.strip() or "NO_RESET",
internal_squad_uuids=config.payment_internal_squad_uuids,
internal_squad_uuids=self._resolve_plan_internal_squad_uuids(
plan_settings,
config=config,
),
external_squad_uuid=config.payment_external_squad_uuid_normalized,
)
@staticmethod
def _resolve_plan_internal_squad_uuids(
plan_settings: PaymentPlanSettings,
*,
config: BotConfigSnapshot,
) -> list[str]:
if not plan_settings.squad_groups:
return config.payment_internal_squad_uuids
values: list[str] = []
for group in plan_settings.squad_groups:
if group == "vpn":
group_values = config.payment_vpn_squad_uuids
elif group == "white":
group_values = config.payment_white_squad_uuids
else:
group_values = []
for squad_uuid in group_values:
if squad_uuid not in values:
values.append(squad_uuid)
return values
@staticmethod
def _apply_discount(*, amount_rub: int, discount_percent: int) -> int:
if discount_percent <= 0:
return amount_rub
return max(1, ceil(amount_rub * (100 - discount_percent) / 100))
async def _get_applied_referral_code(
async def get_active_discount_for_user(self, *, telegram_id: int) -> AppliedDiscount:
config = await self._get_config()
return await self._get_applied_discount(telegram_id, config=config)
async def _get_applied_discount(
self,
telegram_id: int,
*,
config: BotConfigSnapshot | None = None,
) -> str:
) -> AppliedDiscount:
resolved_config = config or await self._get_config()
promo_getter = getattr(self._sync_service, "get_active_promo_for_user", None)
if callable(promo_getter):
promo = await promo_getter(telegram_id)
if promo is not None:
return AppliedDiscount(
code=str(promo.code).strip().upper(),
source="promo",
discount_percent=max(int(promo.discount_percent), 0),
plan_code=promo.plan_code,
expires_at=promo.expires_at,
)
if not resolved_config.referral_enabled:
return ""
return AppliedDiscount()
summary = await self._sync_service.get_referral_summary(telegram_id)
return summary.applied_referral_code.strip().upper()
referral_code = summary.applied_referral_code.strip().upper()
if not referral_code:
return AppliedDiscount()
# Check if referral discount was already consumed by a previous payment
discount_used_checker = getattr(
self._sync_service, "is_referral_discount_used", None
)
if callable(discount_used_checker) and await discount_used_checker(telegram_id):
return AppliedDiscount()
return AppliedDiscount(
code=referral_code,
source="referral",
discount_percent=max(resolved_config.referral_discount_percent, 0),
)
async def apply_pending_referral_bonuses(
self,
@@ -655,6 +784,7 @@ class PaymentService:
self,
order: PaymentOrder,
*,
plan: PaymentPlan,
config: BotConfigSnapshot,
) -> RemnawaveUser:
expire_at = datetime.now(timezone.utc) + timedelta(days=order.plan_duration_days)
@@ -665,7 +795,7 @@ class PaymentService:
"trafficLimitStrategy": order.traffic_limit_strategy,
"telegramId": order.telegram_id,
"description": f"Created by Telegram bot manual payment {order.order_uuid}",
"activeInternalSquads": config.payment_internal_squad_uuids,
"activeInternalSquads": plan.internal_squad_uuids or [],
}
if config.payment_user_tag_normalized:
body["tag"] = config.payment_user_tag_normalized
@@ -678,6 +808,7 @@ class PaymentService:
order: PaymentOrder,
remote_user: RemnawaveUser,
*,
plan: PaymentPlan,
config: BotConfigSnapshot,
) -> RemnawaveUser:
current_expire_at = remote_user.expire_at
@@ -699,7 +830,7 @@ class PaymentService:
"trafficLimitStrategy": order.traffic_limit_strategy,
"telegramId": order.telegram_id,
"description": f"Updated by Telegram bot manual payment {order.order_uuid}",
"activeInternalSquads": config.payment_internal_squad_uuids,
"activeInternalSquads": plan.internal_squad_uuids or [],
}
if config.payment_user_tag_normalized:
body["tag"] = config.payment_user_tag_normalized

View File

@@ -1,11 +1,41 @@
from __future__ import annotations
import math
from dataclasses import dataclass, field
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.base import utcnow
from app.db.models import SupportTicket
OPEN_SUPPORT_TICKET_STATUS = "OPEN"
ANSWERED_SUPPORT_TICKET_STATUS = "ANSWERED"
CLOSED_SUPPORT_TICKET_STATUS = "CLOSED"
@dataclass(slots=True)
class AdminSupportTicketListItem:
id: int
public_id: str
telegram_id: int
username: str | None
display_name: str
status: str
created_at: datetime
updated_at: datetime
closed_at: datetime | None
@dataclass(slots=True)
class AdminSupportTicketsPage:
items: list[AdminSupportTicketListItem] = field(default_factory=list)
page: int = 1
page_size: int = 8
total_items: int = 0
total_pages: int = 1
class SupportTicketService:
def __init__(self, *, session_factory: async_sessionmaker[AsyncSession]) -> None:
@@ -33,13 +63,17 @@ class SupportTicketService:
support_chat_id=support_chat_id,
support_thread_id=support_thread_id,
support_message_id=support_message_id,
status="OPEN",
status=OPEN_SUPPORT_TICKET_STATUS,
)
session.add(ticket)
await session.commit()
await session.refresh(ticket)
return ticket
async def get_ticket(self, *, ticket_id: int) -> SupportTicket | None:
async with self._session_factory() as session:
return await session.get(SupportTicket, ticket_id)
async def get_ticket_by_support_message(
self,
*,
@@ -54,19 +88,121 @@ class SupportTicketService:
)
)
async def mark_answered(self, *, ticket_id: int) -> None:
async def mark_answered(self, *, ticket_id: int) -> SupportTicket | None:
async with self._session_factory() as session:
ticket = await session.get(SupportTicket, ticket_id)
if ticket is None:
return
return None
if ticket.status != CLOSED_SUPPORT_TICKET_STATUS:
ticket.status = ANSWERED_SUPPORT_TICKET_STATUS
ticket.status = "ANSWERED"
ticket.updated_at = utcnow()
await session.commit()
await session.refresh(ticket)
return ticket
async def append_note(
self,
*,
ticket_id: int,
note_text: str,
status: str | None = None,
) -> SupportTicket | None:
cleaned_note = note_text.strip()
if not cleaned_note:
return None
async with self._session_factory() as session:
ticket = await session.get(SupportTicket, ticket_id)
if ticket is None:
return None
if ticket.support_note:
ticket.support_note = f"{ticket.support_note}\n\n{cleaned_note}"
else:
ticket.support_note = cleaned_note
if status and ticket.status != CLOSED_SUPPORT_TICKET_STATUS:
ticket.status = status
ticket.updated_at = utcnow()
await session.commit()
await session.refresh(ticket)
return ticket
async def close_ticket(
self,
*,
ticket_id: int,
closed_by_telegram_id: int | None,
) -> SupportTicket | None:
async with self._session_factory() as session:
ticket = await session.get(SupportTicket, ticket_id)
if ticket is None:
return None
ticket.status = CLOSED_SUPPORT_TICKET_STATUS
ticket.closed_by_telegram_id = closed_by_telegram_id
ticket.closed_at = utcnow()
ticket.updated_at = utcnow()
await session.commit()
await session.refresh(ticket)
return ticket
async def get_open_tickets_count(self) -> int:
async with self._session_factory() as session:
total = await session.scalar(
select(func.count(SupportTicket.id)).where(SupportTicket.status == "OPEN")
select(func.count(SupportTicket.id)).where(
SupportTicket.status != CLOSED_SUPPORT_TICKET_STATUS
)
)
return int(total or 0)
async def get_admin_tickets_page(
self,
*,
page: int,
page_size: int = 8,
) -> AdminSupportTicketsPage:
resolved_page_size = max(1, min(page_size, 20))
async with self._session_factory() as session:
total_items = int(
await session.scalar(select(func.count(SupportTicket.id))) or 0
)
total_pages = max(1, math.ceil(total_items / resolved_page_size)) if total_items else 1
resolved_page = min(max(page, 1), total_pages)
rows = list(
(
await session.scalars(
select(SupportTicket)
.order_by(
SupportTicket.created_at.desc(),
SupportTicket.id.desc(),
)
.offset((resolved_page - 1) * resolved_page_size)
.limit(resolved_page_size)
)
).all()
)
return AdminSupportTicketsPage(
items=[
AdminSupportTicketListItem(
id=row.id,
public_id=row.public_id,
telegram_id=row.telegram_id,
username=row.username,
display_name=row.display_name,
status=row.status,
created_at=row.created_at,
updated_at=row.updated_at,
closed_at=row.closed_at,
)
for row in rows
],
page=resolved_page,
page_size=resolved_page_size,
total_items=total_items,
total_pages=total_pages,
)

View File

@@ -5,15 +5,17 @@ import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from sqlalchemy import delete, func, or_, select
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import Settings
from app.db.base import utcnow
from app.db.models import (
InternalSquad,
PromoCode,
PromoCodeApplication,
ReferralCode,
ReferralInvite,
InternalSquad,
RemnawaveUser,
RemnawaveUserInternalSquad,
SubscriptionRequestLog,
@@ -36,9 +38,33 @@ class ReferralSummary:
total_invited: int = 0
referral_code: str = ""
applied_referral_code: str = ""
applied_promo_code: str = ""
applied_promo_discount_percent: int = 0
applied_promo_plan_code: str | None = None
applied_promo_expires_at: datetime | None = None
recent_names: list[str] = field(default_factory=list)
@dataclass(slots=True)
class PromoCodeView:
code: str
plan_code: str | None
discount_percent: int
expires_at: datetime | None
is_active: bool
created_by_telegram_id: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
@property
def is_expired(self) -> bool:
return self.expires_at is not None and self.expires_at <= utcnow()
@property
def is_valid(self) -> bool:
return self.is_active and not self.is_expired and self.discount_percent > 0
@dataclass(slots=True)
class AdminStats:
total_telegram_users: int = 0
@@ -174,6 +200,124 @@ class SyncService:
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
await session.commit()
async def create_promo_code(
self,
*,
code: str,
plan_code: str | None,
discount_percent: int,
expires_at: datetime | None,
created_by_telegram_id: int,
) -> PromoCodeView:
normalized_code = self.normalize_discount_code(code)
if not normalized_code:
raise ValueError("Введите слово для промокода.")
if len(normalized_code) > 32:
raise ValueError("Промокод должен быть не длиннее 32 символов.")
if not all(ch.isalnum() or ch in "_-" for ch in normalized_code):
raise ValueError("Промокод может содержать только буквы, цифры, _ и -.")
resolved_discount = int(discount_percent)
if resolved_discount <= 0 or resolved_discount > 100:
raise ValueError("Скидка должна быть от 1 до 100%.")
resolved_plan_code = (plan_code or "").strip() or None
if resolved_plan_code and len(resolved_plan_code) > 64:
raise ValueError("Код тарифа слишком длинный.")
resolved_expires_at = self._normalize_datetime(expires_at)
if resolved_expires_at is not None and resolved_expires_at <= utcnow():
raise ValueError("Срок действия промокода должен быть в будущем.")
async with self._session_factory() as session:
duplicate = await session.scalar(
select(PromoCode.id).where(PromoCode.code == normalized_code)
)
if duplicate is not None:
raise ValueError("Такой промокод уже существует.")
promo = PromoCode(
code=normalized_code,
plan_code=resolved_plan_code,
discount_percent=resolved_discount,
expires_at=resolved_expires_at,
is_active=True,
created_by_telegram_id=created_by_telegram_id,
)
session.add(promo)
await session.commit()
await session.refresh(promo)
return self._promo_code_view(promo)
async def get_admin_promo_codes(self, *, limit: int = 20) -> list[PromoCodeView]:
resolved_limit = max(1, min(limit, 100))
async with self._session_factory() as session:
rows = list(
(
await session.scalars(
select(PromoCode)
.order_by(PromoCode.created_at.desc(), PromoCode.id.desc())
.limit(resolved_limit)
)
).all()
)
return [self._promo_code_view(row) for row in rows]
async def get_active_promo_for_user(self, telegram_id: int) -> PromoCodeView | None:
async with self._session_factory() as session:
promo = await self._get_active_promo_for_user(session, telegram_id=telegram_id)
return self._promo_code_view(promo) if promo is not None else None
async def consume_discount_for_user(self, telegram_id: int) -> None:
"""Burn the applied promo / referral discount so it cannot be reused.
- For promo codes: deletes the row from ``promo_code_applications``.
- For referral invites: sets ``discount_used = True`` so the referral
bonus logic (inviter days) is preserved while the invited user
no longer receives a price discount.
- Sets ``discount_ever_used = True`` on ``telegram_users`` to permanently
prevent any future discount code activation.
"""
async with self._session_factory() as session:
# 1. Burn promo-code application
await session.execute(
delete(PromoCodeApplication).where(
PromoCodeApplication.telegram_id == telegram_id
)
)
# 2. Mark referral invite discount as used
await session.execute(
update(ReferralInvite)
.where(
ReferralInvite.invited_telegram_id == telegram_id,
ReferralInvite.discount_used == False, # noqa: E712
)
.values(discount_used=True)
)
# 3. Permanently mark the user so no new codes can be activated
await session.execute(
update(TelegramUser)
.where(
TelegramUser.telegram_id == telegram_id,
TelegramUser.discount_ever_used == False, # noqa: E712
)
.values(discount_ever_used=True)
)
await session.commit()
async def is_referral_discount_used(self, telegram_id: int) -> bool:
"""Return True if the referral invite discount was already consumed."""
async with self._session_factory() as session:
discount_used = await session.scalar(
select(ReferralInvite.discount_used).where(
ReferralInvite.invited_telegram_id == telegram_id
)
)
return bool(discount_used)
async def apply_referral_code(
self,
*,
@@ -193,8 +337,28 @@ class SyncService:
last_name=last_name,
language_code=language_code,
)
# Block activation if the user has already used a discount
if invited.discount_ever_used:
raise ValueError(
"Вы уже использовали промокод или реферальный код. "
"Повторная активация невозможна."
)
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
raw_code = self.normalize_discount_code(referral_code)
promo = await self._find_promo_code(session, code=raw_code)
if promo is not None:
self._ensure_promo_can_be_applied(promo)
await self._upsert_promo_application(
session,
telegram_id=telegram_id,
promo_code_id=promo.id,
)
await session.commit()
return promo.code
existing = await session.scalar(
select(ReferralInvite).where(
ReferralInvite.invited_telegram_id == telegram_id
@@ -217,6 +381,11 @@ class SyncService:
if not invite_created:
raise ValueError("Не удалось применить реферальный код.")
await session.execute(
delete(PromoCodeApplication).where(
PromoCodeApplication.telegram_id == telegram_id
)
)
await session.commit()
return referral_code.strip().upper()
@@ -247,11 +416,21 @@ class SyncService:
)
.where(ReferralInvite.invited_telegram_id == telegram_id)
)
applied_promo = await self._get_active_promo_for_user(
session,
telegram_id=telegram_id,
)
return ReferralSummary(
total_invited=int(total_invited or 0),
referral_code=referral_code,
applied_referral_code=applied_referral_code or "",
applied_promo_code=applied_promo.code if applied_promo is not None else "",
applied_promo_discount_percent=(
int(applied_promo.discount_percent) if applied_promo is not None else 0
),
applied_promo_plan_code=applied_promo.plan_code if applied_promo is not None else None,
applied_promo_expires_at=applied_promo.expires_at if applied_promo is not None else None,
recent_names=recent_names,
)
@@ -623,6 +802,11 @@ class SyncService:
remote_user: RemoteRemnawaveUser,
owner: TelegramUser | None,
) -> RemnawaveUser:
owner_id = owner.id if owner is not None else None
if owner_id is None and remote_user.telegram_id is not None:
with session.no_autoflush:
owner_id = await self._find_owner_id(session, remote_user.telegram_id)
record = await session.scalar(
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
)
@@ -631,8 +815,6 @@ class SyncService:
record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
session.add(record)
owner_id = owner.id if owner is not None else await self._find_owner_id(session, remote_user.telegram_id)
record.owner_telegram_user_id = owner_id
record.rw_id = remote_user.id
record.short_uuid = remote_user.short_uuid
@@ -781,6 +963,62 @@ class SyncService:
select(ReferralCode.telegram_id).where(ReferralCode.code == raw_code)
)
async def _find_promo_code(
self,
session: AsyncSession,
*,
code: str,
) -> PromoCode | None:
raw_code = self.normalize_discount_code(code)
if not raw_code:
return None
return await session.scalar(
select(PromoCode).where(PromoCode.code == raw_code)
)
async def _get_active_promo_for_user(
self,
session: AsyncSession,
*,
telegram_id: int,
) -> PromoCode | None:
promo = await session.scalar(
select(PromoCode)
.join(
PromoCodeApplication,
PromoCodeApplication.promo_code_id == PromoCode.id,
)
.where(PromoCodeApplication.telegram_id == telegram_id)
)
if promo is None or not self._promo_is_valid(promo):
return None
return promo
async def _upsert_promo_application(
self,
session: AsyncSession,
*,
telegram_id: int,
promo_code_id: int,
) -> None:
application = await session.scalar(
select(PromoCodeApplication).where(
PromoCodeApplication.telegram_id == telegram_id
)
)
if application is None:
session.add(
PromoCodeApplication(
telegram_id=telegram_id,
promo_code_id=promo_code_id,
)
)
return
application.promo_code_id = promo_code_id
application.updated_at = utcnow()
async def _find_owner_id(self, session: AsyncSession, telegram_id: int | None) -> int | None:
if telegram_id is None:
return None
@@ -801,6 +1039,40 @@ class SyncService:
def _maybe_str(value: object | None) -> str | None:
return str(value) if value is not None else None
@staticmethod
def normalize_discount_code(value: str | None) -> str:
return "".join((value or "").strip().upper().split())
@classmethod
def _ensure_promo_can_be_applied(cls, promo: PromoCode) -> None:
if not promo.is_active:
raise ValueError("Промокод отключён.")
if cls._promo_is_expired(promo):
raise ValueError("Срок действия промокода истёк.")
if promo.discount_percent <= 0:
raise ValueError("Промокод не даёт скидку.")
@classmethod
def _promo_is_valid(cls, promo: PromoCode) -> bool:
return bool(promo.is_active) and not cls._promo_is_expired(promo) and promo.discount_percent > 0
@staticmethod
def _promo_is_expired(promo: PromoCode) -> bool:
return promo.expires_at is not None and promo.expires_at <= utcnow()
@staticmethod
def _promo_code_view(promo: PromoCode) -> PromoCodeView:
return PromoCodeView(
code=promo.code,
plan_code=promo.plan_code,
discount_percent=int(promo.discount_percent),
expires_at=promo.expires_at,
is_active=bool(promo.is_active),
created_by_telegram_id=promo.created_by_telegram_id,
created_at=promo.created_at,
updated_at=promo.updated_at,
)
@staticmethod
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
view_map: dict[int, CachedUserView] = {}