feat: add period-based discounts for promo groups

This commit is contained in:
Egor
2025-09-24 05:04:19 +03:00
parent c4bf18cf0f
commit 084363b3d6
11 changed files with 623 additions and 84 deletions

View File

@@ -1,5 +1,5 @@
import logging
from typing import List, Optional, Tuple
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,6 +7,24 @@ from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
if not period_discounts:
return {}
normalized: Dict[int, int] = {}
for key, value in period_discounts.items():
try:
period = int(key)
percent = int(value)
except (TypeError, ValueError):
continue
normalized[period] = max(0, min(100, percent))
return normalized
logger = logging.getLogger(__name__)
@@ -40,12 +58,16 @@ async def create_promo_group(
server_discount_percent: int,
traffic_discount_percent: int,
device_discount_percent: int,
period_discounts: Optional[Dict[int, int]] = None,
) -> PromoGroup:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
promo_group = PromoGroup(
name=name.strip(),
server_discount_percent=max(0, min(100, server_discount_percent)),
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
device_discount_percent=max(0, min(100, device_discount_percent)),
period_discounts=normalized_period_discounts or None,
is_default=False,
)
@@ -54,11 +76,12 @@ async def create_promo_group(
await db.refresh(promo_group)
logger.info(
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)",
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s)",
promo_group.name,
promo_group.server_discount_percent,
promo_group.traffic_discount_percent,
promo_group.device_discount_percent,
normalized_period_discounts,
)
return promo_group
@@ -72,6 +95,7 @@ async def update_promo_group(
server_discount_percent: Optional[int] = None,
traffic_discount_percent: Optional[int] = None,
device_discount_percent: Optional[int] = None,
period_discounts: Optional[Dict[int, int]] = None,
) -> PromoGroup:
if name is not None:
group.name = name.strip()
@@ -81,6 +105,9 @@ async def update_promo_group(
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
if device_discount_percent is not None:
group.device_discount_percent = max(0, min(100, device_discount_percent))
if period_discounts is not None:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
group.period_discounts = normalized_period_discounts or None
await db.commit()
await db.refresh(group)

View File

@@ -531,7 +531,15 @@ async def calculate_subscription_total_cost(
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
base_price_original = PERIOD_PRICES.get(period_days, 0)
period_discount_percent = _get_discount_percent(
user,
promo_group,
"period",
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
@@ -577,6 +585,9 @@ async def calculate_subscription_total_cost(
details = {
'base_price': base_price,
'base_price_original': base_price_original,
'base_discount_percent': period_discount_percent,
'base_discount_total': base_discount_total,
'traffic_price_per_month': traffic_price_per_month,
'traffic_discount_percent': traffic_discount_percent,
'traffic_discount_total': total_traffic_discount,

View File

@@ -1,5 +1,5 @@
from datetime import datetime, timedelta
from typing import Optional, List
from typing import Optional, List, Dict
from enum import Enum
from sqlalchemy import (
@@ -270,13 +270,59 @@ class PromoGroup(Base):
server_discount_percent = Column(Integer, nullable=False, default=0)
traffic_discount_percent = Column(Integer, nullable=False, default=0)
device_discount_percent = Column(Integer, nullable=False, default=0)
period_discounts = Column(JSON, nullable=True, default=dict)
is_default = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
users = relationship("User", back_populates="promo_group")
def _get_period_discounts_map(self) -> Dict[int, int]:
raw_discounts = self.period_discounts or {}
if isinstance(raw_discounts, dict):
items = raw_discounts.items()
else:
items = []
normalized: Dict[int, int] = {}
for key, value in items:
try:
period = int(key)
percent = int(value)
except (TypeError, ValueError):
continue
normalized[period] = max(0, min(100, percent))
return normalized
def _get_period_discount(self, period_days: Optional[int]) -> int:
if not period_days:
return 0
discounts = self._get_period_discounts_map()
if period_days in discounts:
return discounts[period_days]
if self.is_default:
try:
from app.config import settings
if settings.is_base_promo_group_period_discount_enabled():
config_discounts = settings.get_base_promo_group_period_discounts()
return config_discounts.get(period_days, 0)
except Exception:
return 0
return 0
def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int:
if category == "period":
return max(0, min(100, self._get_period_discount(period_days)))
mapping = {
"servers": self.server_discount_percent,
"traffic": self.traffic_discount_percent,
@@ -284,18 +330,6 @@ class PromoGroup(Base):
}
percent = mapping.get(category, 0)
if self.is_default and period_days is not None:
try:
from app.config import settings
if settings.is_base_promo_group_period_discount_enabled():
discounts = settings.get_base_promo_group_period_discounts()
if period_days in discounts:
period_discount = discounts[period_days]
percent = period_discount
except Exception:
pass
return max(0, min(100, percent))

View File

@@ -681,6 +681,46 @@ async def ensure_promo_groups_setup():
f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}"
)
period_discounts_column_exists = await check_column_exists(
"promo_groups", "period_discounts"
)
if not period_discounts_column_exists:
if db_type == "sqlite":
await conn.execute(
text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON")
)
await conn.execute(
text("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL")
)
elif db_type == "postgresql":
await conn.execute(
text(
"ALTER TABLE promo_groups ADD COLUMN period_discounts JSONB"
)
)
await conn.execute(
text(
"UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL"
)
)
elif db_type == "mysql":
await conn.execute(
text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON")
)
await conn.execute(
text(
"UPDATE promo_groups SET period_discounts = JSON_OBJECT() WHERE period_discounts IS NULL"
)
)
else:
logger.error(
f"Неподдерживаемый тип БД для promo_groups.period_discounts: {db_type}"
)
return False
logger.info("Добавлена колонка promo_groups.period_discounts")
column_exists = await check_column_exists("users", "promo_group_id")
if not column_exists:
@@ -1543,7 +1583,8 @@ async def check_migration_status():
"subscription_duplicates": False,
"subscription_conversions_table": False,
"promo_groups_table": False,
"users_promo_group_column": False
"users_promo_group_column": False,
"promo_groups_period_discounts_column": False,
}
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
@@ -1556,6 +1597,7 @@ async def check_migration_status():
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id')
status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts')
media_fields_exist = (
await check_column_exists('broadcast_history', 'has_media') and
@@ -1587,7 +1629,8 @@ async def check_migration_status():
"subscription_conversions_table": "Таблица конверсий подписок",
"subscription_duplicates": "Отсутствие дубликатов подписок",
"promo_groups_table": "Таблица промо-групп",
"users_promo_group_column": "Колонка promo_group_id у пользователей"
"users_promo_group_column": "Колонка promo_group_id у пользователей",
"promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп",
}
for check_key, check_status in status.items():

View File

@@ -1,10 +1,12 @@
import logging
from typing import Optional
import logging
from typing import Dict, Optional
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.promo_group import (
get_promo_groups_with_counts,
get_promo_group_by_id,
@@ -22,7 +24,7 @@ from app.keyboards.admin import (
get_admin_pagination_keyboard,
get_confirmation_keyboard,
)
from app.utils.pricing_utils import format_period_description
logger = logging.getLogger(__name__)
@@ -37,6 +39,128 @@ def _format_discount_line(texts, group) -> str:
)
def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]:
if not raw or not isinstance(raw, dict):
return {}
normalized: Dict[int, int] = {}
for key, value in raw.items():
try:
period = int(key)
percent = int(value)
except (TypeError, ValueError):
continue
normalized[period] = max(0, min(100, percent))
return normalized
def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]:
discounts = _normalize_periods_dict(getattr(group, "period_discounts", None))
if discounts:
return dict(sorted(discounts.items()))
if group.is_default and settings.is_base_promo_group_period_discount_enabled():
try:
base_discounts = settings.get_base_promo_group_period_discounts()
normalized = _normalize_periods_dict(base_discounts)
return dict(sorted(normalized.items()))
except Exception:
return {}
return {}
def _format_period_discounts_lines(texts, group: PromoGroup, language: str) -> list:
discounts = _collect_period_discounts(group)
if not discounts:
return []
header = texts.t(
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER",
"⏳ Скидки по периодам:",
)
lines = [header]
for period_days, percent in discounts.items():
period_display = format_period_description(period_days, language)
lines.append(
texts.t("PROMO_GROUP_PERIOD_DISCOUNT_ITEM", "{period}{percent}%").format(
period=period_display,
percent=percent,
)
)
return lines
def _format_period_discounts_value(discounts: Dict[int, int]) -> str:
if not discounts:
return "0"
return ", ".join(
f"{period}:{percent}"
for period, percent in sorted(discounts.items())
)
def _parse_period_discounts_input(value: str) -> Dict[int, int]:
cleaned = (value or "").strip()
if not cleaned or cleaned in {"0", "-"}:
return {}
cleaned = cleaned.replace(";", ",").replace("\n", ",")
parts = [part.strip() for part in cleaned.split(",") if part.strip()]
if not parts:
return {}
discounts: Dict[int, int] = {}
for part in parts:
if ":" not in part:
raise ValueError
period_raw, percent_raw = part.split(":", 1)
period = int(period_raw.strip())
percent = int(percent_raw.strip())
if period <= 0:
raise ValueError
discounts[period] = max(0, min(100, percent))
return discounts
async def _prompt_for_period_discounts(
message: types.Message,
state: FSMContext,
prompt_key: str,
default_text: str,
*,
current_value: Optional[str] = None,
):
data = await state.get_data()
texts = get_texts(data.get("language", "ru"))
prompt_text = texts.t(prompt_key, default_text)
if current_value is not None:
try:
prompt_text = prompt_text.format(current=current_value)
except KeyError:
pass
await message.answer(prompt_text)
@admin_required
@error_handler
async def show_promo_groups_menu(
@@ -64,17 +188,20 @@ async def show_promo_groups_menu(
if group.is_default
else ""
)
lines.extend(
[
f"{'' if group.is_default else '🎯'} <b>{group.name}</b>{default_suffix}",
_format_discount_line(texts, group),
texts.t(
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
"Участников: {count}",
).format(count=member_count),
"",
]
)
group_lines = [
f"{'' if group.is_default else '🎯'} <b>{group.name}</b>{default_suffix}",
_format_discount_line(texts, group),
texts.t(
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
"Участников: {count}",
).format(count=member_count),
]
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
group_lines.extend(period_lines)
group_lines.append("")
lines.extend(group_lines)
keyboard_rows.append([
types.InlineKeyboardButton(
text=f"{'' if group.is_default else '🎯'} {group.name}",
@@ -127,25 +254,30 @@ async def show_promo_group_details(
member_count = await count_promo_group_members(db, group.id)
default_note = (
"\n" + texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.")
texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.")
if group.is_default
else ""
)
text = "\n".join(
[
texts.t(
"ADMIN_PROMO_GROUP_DETAILS_TITLE",
"💳 <b>Промогруппа:</b> {name}",
).format(name=group.name),
_format_discount_line(texts, group),
texts.t(
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
"Участников: {count}",
).format(count=member_count),
default_note,
]
)
lines = [
texts.t(
"ADMIN_PROMO_GROUP_DETAILS_TITLE",
"💳 <b>Промогруппа:</b> {name}",
).format(name=group.name),
_format_discount_line(texts, group),
texts.t(
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
"Участников: {count}",
).format(count=member_count),
]
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
lines.extend(period_lines)
if default_note:
lines.append(default_note)
text = "\n".join(line for line in lines if line)
keyboard_rows = []
if member_count > 0:
@@ -299,13 +431,47 @@ async def process_create_group_devices(
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
await state.update_data(new_group_devices=devices_discount)
await state.set_state(AdminStates.creating_promo_group_period_discount)
await _prompt_for_period_discounts(
message,
state,
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT",
"Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
)
@admin_required
@error_handler
async def process_create_group_period_discounts(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
period_discounts = _parse_period_discounts_input(message.text)
except ValueError:
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS",
"Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
)
)
return
try:
group = await create_promo_group(
db,
data["new_group_name"],
traffic_discount_percent=data["new_group_traffic"],
server_discount_percent=data["new_group_servers"],
device_discount_percent=devices_discount,
device_discount_percent=data["new_group_devices"],
period_discounts=period_discounts,
)
except Exception as e:
logger.error(f"Не удалось создать промогруппу: {e}")
@@ -440,13 +606,55 @@ async def process_edit_group_devices(
await state.clear()
return
await state.update_data(edit_group_devices=devices_discount)
await state.set_state(AdminStates.editing_promo_group_period_discount)
current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None))
await _prompt_for_period_discounts(
message,
state,
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT",
"Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
current_value=_format_period_discounts_value(current_discounts),
)
@admin_required
@error_handler
async def process_edit_group_period_discounts(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
period_discounts = _parse_period_discounts_input(message.text)
except ValueError:
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS",
"Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
)
)
return
group = await get_promo_group_by_id(db, data["edit_group_id"])
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
await update_promo_group(
db,
group,
name=data["edit_group_name"],
traffic_discount_percent=data["edit_group_traffic"],
server_discount_percent=data["edit_group_servers"],
device_discount_percent=devices_discount,
device_discount_percent=data["edit_group_devices"],
period_discounts=period_discounts,
)
await state.clear()
@@ -616,6 +824,10 @@ def register_handlers(dp: Dispatcher):
process_create_group_devices,
AdminStates.creating_promo_group_device_discount,
)
dp.message.register(
process_create_group_period_discounts,
AdminStates.creating_promo_group_period_discount,
)
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
dp.message.register(
@@ -630,3 +842,7 @@ def register_handlers(dp: Dispatcher):
process_edit_group_devices,
AdminStates.editing_promo_group_device_discount,
)
dp.message.register(
process_edit_group_period_discounts,
AdminStates.editing_promo_group_period_discount,
)

View File

@@ -103,7 +103,15 @@ async def _prepare_subscription_summary(
months_in_period = calculate_months_from_days(summary_data['period_days'])
period_display = format_period_description(summary_data['period_days'], db_user.language)
base_price = PERIOD_PRICES[summary_data['period_days']]
base_price_original = PERIOD_PRICES[summary_data['period_days']]
period_discount_percent = db_user.get_promo_discount(
"period",
summary_data['period_days'],
)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
if settings.is_traffic_fixed():
traffic_limit = settings.get_fixed_traffic_limit()
@@ -195,6 +203,9 @@ async def _prepare_subscription_summary(
summary_data['server_prices_for_period'] = selected_server_prices
summary_data['months_in_period'] = months_in_period
summary_data['base_price'] = base_price
summary_data['base_price_original'] = base_price_original
summary_data['base_discount_percent'] = period_discount_percent
summary_data['base_discount_total'] = base_discount_total
summary_data['final_traffic_gb'] = final_traffic_gb
summary_data['traffic_price_per_month'] = traffic_price_per_month
summary_data['traffic_discount_percent'] = traffic_component["discount_percent"]
@@ -226,7 +237,15 @@ async def _prepare_subscription_summary(
else:
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
details_lines = [f"- Базовый период: {texts.format_price(base_price)}"]
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
if base_discount_total > 0:
base_line += (
f"{texts.format_price(base_price)}"
f" (скидка {period_discount_percent}%:"
f" -{texts.format_price(base_discount_total)})"
)
details_lines = [base_line]
if total_traffic_price > 0:
traffic_line = (
@@ -317,26 +336,29 @@ def _build_promo_group_discount_text(
period_lines: List[str] = []
if (
promo_group.is_default
and periods
and settings.is_base_promo_group_period_discount_enabled()
):
discounts = settings.get_base_promo_group_period_discounts()
period_candidates: set[int] = set(periods or [])
for period_days in periods:
percent = discounts.get(period_days, 0)
if percent <= 0:
raw_period_discounts = getattr(promo_group, "period_discounts", None)
if isinstance(raw_period_discounts, dict):
for key in raw_period_discounts.keys():
try:
period_candidates.add(int(key))
except (TypeError, ValueError):
continue
period_display = format_period_description(period_days, db_user.language)
period_lines.append(
texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format(
period=period_display,
percent=percent,
)
for period_days in sorted(period_candidates):
percent = promo_group.get_discount_percent("period", period_days)
if percent <= 0:
continue
period_display = format_period_description(period_days, db_user.language)
period_lines.append(
texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format(
period=period_display,
percent=percent,
)
)
if not service_lines and not period_lines:
return ""
@@ -666,8 +688,26 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
subscription_service = SubscriptionService()
base_cost = PERIOD_PRICES.get(30, 0)
base_cost_original = PERIOD_PRICES.get(30, 0)
try:
owner = subscription.user
except AttributeError:
owner = None
period_discount_percent = 0
if owner:
try:
period_discount_percent = owner.get_promo_discount("period", 30)
except AttributeError:
period_discount_percent = 0
from app.utils.pricing_utils import apply_percentage_discount
base_cost, _ = apply_percentage_discount(
base_cost_original,
period_discount_percent,
)
try:
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads, db
@@ -683,7 +723,14 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:")
logger.info(f" 📅 Базовый тариф (30 дней): {base_cost/100}")
base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original/100}"
if period_discount_percent > 0:
discount_value = base_cost_original * period_discount_percent // 100
base_log += (
f"{base_cost/100}"
f" (скидка {period_discount_percent}%: -{discount_value/100}₽)"
)
logger.info(base_log)
if servers_cost > 0:
logger.info(f" 🌍 Серверы: {servers_cost/100}")
if traffic_cost > 0:
@@ -1801,7 +1848,14 @@ async def handle_extend_subscription(
months_in_period = calculate_months_from_days(days)
from app.config import PERIOD_PRICES
base_price = PERIOD_PRICES.get(days, 0)
from app.utils.pricing_utils import apply_percentage_discount
base_price_original = PERIOD_PRICES.get(days, 0)
period_discount_percent = db_user.get_promo_discount("period", days)
base_price, _ = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads, db
@@ -2015,7 +2069,11 @@ async def confirm_extend_subscription(
db_user: User,
db: AsyncSession
):
from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation
from app.utils.pricing_utils import (
calculate_months_from_days,
validate_pricing_calculation,
apply_percentage_discount,
)
from app.services.admin_notification_service import AdminNotificationService
days = int(callback.data.split('_')[2])
@@ -2032,8 +2090,14 @@ async def confirm_extend_subscription(
try:
from app.config import PERIOD_PRICES
from app.utils.pricing_utils import apply_percentage_discount
base_price = PERIOD_PRICES.get(days, 0)
base_price_original = PERIOD_PRICES.get(days, 0)
period_discount_percent = db_user.get_promo_discount("period", days)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
subscription_service = SubscriptionService()
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
@@ -2091,7 +2155,13 @@ async def confirm_extend_subscription(
return
logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период {days} дней: {base_price/100}")
base_log = f" 📅 Период {days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
if total_servers_price > 0:
logger.info(
f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period}"
@@ -2543,7 +2613,15 @@ async def select_country(
countries = await _get_available_countries()
base_price = PERIOD_PRICES[data['period_days']] + settings.get_traffic_price(data['traffic_gb'])
period_base_price = PERIOD_PRICES[data['period_days']]
from app.utils.pricing_utils import apply_percentage_discount
discounted_base_price, _ = apply_percentage_discount(
period_base_price,
db_user.get_promo_discount("period", data['period_days']),
)
base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb'])
try:
subscription_service = SubscriptionService()
@@ -2683,7 +2761,34 @@ async def confirm_purchase(
'months_in_period', calculate_months_from_days(data['period_days'])
)
base_price = data.get('base_price', PERIOD_PRICES[data['period_days']])
base_price = data.get('base_price')
base_price_original = data.get('base_price_original')
base_discount_percent = data.get('base_discount_percent')
base_discount_total = data.get('base_discount_total')
if base_price is None:
base_price_original = PERIOD_PRICES[data['period_days']]
base_discount_percent = db_user.get_promo_discount(
"period",
data['period_days'],
)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
base_discount_percent,
)
else:
if base_price_original is None:
base_price_original = PERIOD_PRICES[data['period_days']]
if base_discount_percent is None:
base_discount_percent = db_user.get_promo_discount(
"period",
data['period_days'],
)
if base_discount_total is None:
_, base_discount_total = apply_percentage_discount(
base_price_original,
base_discount_percent,
)
server_prices = data.get('server_prices_for_period', [])
if not server_prices:
@@ -2812,7 +2917,13 @@ async def confirm_purchase(
return
logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):")
logger.info(f" Период: {base_price/100}")
base_log = f" Период: {base_price_original/100}"
if base_discount_total and base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {base_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
if total_traffic_price > 0:
message = (
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}"

View File

@@ -299,7 +299,15 @@ class SubscriptionService:
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
base_price = PERIOD_PRICES.get(period_days, 0)
base_price_original = PERIOD_PRICES.get(period_days, 0)
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
"period",
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
@@ -353,7 +361,13 @@ class SubscriptionService:
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
logger.info(f"Расчет стоимости новой подписки:")
logger.info(f" Период {period_days} дней: {base_price/100}")
base_log = f" Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
if discounted_traffic_price > 0:
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}"
if traffic_discount > 0:
@@ -391,7 +405,7 @@ class SubscriptionService:
try:
from app.config import PERIOD_PRICES
base_price = PERIOD_PRICES.get(period_days, 0)
base_price_original = PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, "user", None)
@@ -430,6 +444,15 @@ class SubscriptionService:
traffic_discount = traffic_price * traffic_discount_percent // 100
discounted_traffic_price = traffic_price - traffic_discount
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
"period",
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
total_price = (
base_price
+ discounted_servers_price
@@ -438,7 +461,13 @@ class SubscriptionService:
)
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
if servers_price > 0:
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}"
if servers_discount > 0:
@@ -577,7 +606,15 @@ class SubscriptionService:
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
base_price_original = PERIOD_PRICES.get(period_days, 0)
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
"period",
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
@@ -637,7 +674,13 @@ class SubscriptionService:
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Период {period_days} дней: {base_price/100}")
base_log = f" Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
if total_traffic_price > 0:
message = (
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}"
@@ -681,7 +724,7 @@ class SubscriptionService:
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
base_price_original = PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, "user", None)
@@ -723,10 +766,25 @@ class SubscriptionService:
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_price = discounted_traffic_per_month * months_in_period
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
"period",
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
if total_servers_price > 0:
message = (
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}"

View File

@@ -68,11 +68,13 @@ class AdminStates(StatesGroup):
creating_promo_group_traffic_discount = State()
creating_promo_group_server_discount = State()
creating_promo_group_device_discount = State()
creating_promo_group_period_discount = State()
editing_promo_group_name = State()
editing_promo_group_traffic_discount = State()
editing_promo_group_server_discount = State()
editing_promo_group_device_discount = State()
editing_promo_group_period_discount = State()
editing_squad_price = State()
editing_traffic_price = State()

View File

@@ -137,6 +137,7 @@
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
@@ -224,13 +225,16 @@
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):",
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.",
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.",
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups",
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.",
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",

View File

@@ -15,6 +15,7 @@
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
@@ -102,13 +103,16 @@
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):",
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",

View File

@@ -0,0 +1,29 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "4b6b0f58c8f9"
down_revision: Union[str, None] = "1f5f3a3f5a4d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
dialect = bind.dialect.name if bind else ""
op.add_column(
"promo_groups",
sa.Column("period_discounts", sa.JSON(), nullable=True),
)
if dialect == "postgresql":
op.execute("UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL")
else:
op.execute("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL")
def downgrade() -> None:
op.drop_column("promo_groups", "period_discounts")