mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-06 14:03:07 +00:00
Merge pull request #1775 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-fo5kxf
Bedolaga/add paid trial activation feature fo5kxf
This commit is contained in:
@@ -85,7 +85,8 @@ REMNAWAVE_USER_DELETE_MODE=delete
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_SQUAD_UUID=
|
||||
TRIAL_PAYMENT_ENABLED=false
|
||||
TRIAL_ACTIVATION_PRICE=0
|
||||
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
|
||||
@@ -538,6 +538,12 @@ TRAFFIC_PACKAGES_CONFIG="100:15000:true"
|
||||
# Бесплатные устройства в триал подписке
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
|
||||
# Требовать оплату за активацию триала
|
||||
TRIAL_PAYMENT_ENABLED=false
|
||||
|
||||
# Стоимость активации триала (в копейках)
|
||||
TRIAL_ACTIVATION_PRICE=0
|
||||
|
||||
# Бесплатные устройства в платной подписке
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
|
||||
|
||||
@@ -85,9 +85,10 @@ class Settings(BaseSettings):
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: Optional[str] = None
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
@@ -883,6 +884,24 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_disabled_mode_device_limit(self) -> Optional[int]:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_trial_paid_activation_enabled(self) -> bool:
|
||||
return bool(self.TRIAL_PAYMENT_ENABLED)
|
||||
|
||||
def get_trial_activation_price(self) -> int:
|
||||
try:
|
||||
value = int(self.TRIAL_ACTIVATION_PRICE)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Некорректное значение TRIAL_ACTIVATION_PRICE: %s",
|
||||
self.TRIAL_ACTIVATION_PRICE,
|
||||
)
|
||||
return 0
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
|
||||
return value
|
||||
|
||||
def is_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
|
||||
@@ -515,7 +515,6 @@ async def choose_random_trial_server_squad(
|
||||
|
||||
async def get_random_trial_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
|
||||
squad = await choose_random_trial_server_squad(db)
|
||||
@@ -523,7 +522,7 @@ async def get_random_trial_squad_uuid(
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return fallback_uuid
|
||||
return None
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
@@ -55,10 +55,7 @@ async def create_trial_subscription(
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
squad_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
|
||||
if squad_uuid:
|
||||
logger.debug(
|
||||
@@ -72,7 +69,6 @@ async def create_trial_subscription(
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
squad_uuid = settings.TRIAL_SQUAD_UUID
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
|
||||
@@ -96,6 +96,24 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
label_en="📱 Device limit",
|
||||
action="input",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_PAYMENT_ENABLED",
|
||||
section="trial",
|
||||
label_ru="💳 Платная активация",
|
||||
label_en="💳 Paid activation",
|
||||
action="toggle",
|
||||
description_ru="Если включено — за активацию триала будет списываться указанная сумма.",
|
||||
description_en="When enabled, the configured amount is charged during trial activation.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_ACTIVATION_PRICE",
|
||||
section="trial",
|
||||
label_ru="💰 Стоимость активации",
|
||||
label_en="💰 Activation price",
|
||||
action="price",
|
||||
description_ru="Указывается в копейках. 0 — бесплатная активация.",
|
||||
description_en="Amount in kopeks. 0 — free activation.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_ADD_REMAINING_DAYS_TO_PAID",
|
||||
section="trial",
|
||||
@@ -105,15 +123,6 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
description_ru="Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.",
|
||||
description_en="When enabled, remaining trial days are added to paid subscription duration.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_SQUAD_UUID",
|
||||
section="trial",
|
||||
label_ru="🆔 Squad UUID",
|
||||
label_en="🆔 Squad UUID",
|
||||
action="input",
|
||||
description_ru="Можно оставить пустым, если не требуется назначать пробные подписки в конкретный Squad.",
|
||||
description_en="Leave empty if trial subscriptions shouldn't be bound to a specific Squad.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -290,11 +299,14 @@ def _format_trial_summary(lang_code: str) -> str:
|
||||
duration = settings.TRIAL_DURATION_DAYS
|
||||
traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
devices = settings.TRIAL_DEVICE_LIMIT
|
||||
price_note = ""
|
||||
if settings.is_trial_paid_activation_enabled():
|
||||
price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}"
|
||||
|
||||
traffic_label = _format_traffic_label(traffic, lang_code, short=True)
|
||||
devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱"
|
||||
days_suffix = "д" if lang_code == "ru" else "d"
|
||||
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}"
|
||||
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}"
|
||||
|
||||
|
||||
def _format_core_summary(lang_code: str) -> str:
|
||||
|
||||
@@ -3836,10 +3836,7 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
getattr(settings, "TRIAL_SQUAD_UUID", None),
|
||||
)
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
@@ -3848,8 +3845,6 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
admin_id,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
|
||||
@@ -50,7 +50,7 @@ from app.keyboards.inline import (
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
get_subscription_checkout_draft,
|
||||
@@ -58,6 +58,14 @@ from app.services.subscription_checkout_service import (
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.trial_activation_service import (
|
||||
TrialPaymentChargeFailed,
|
||||
TrialPaymentInsufficientFunds,
|
||||
charge_trial_activation_if_required,
|
||||
preview_trial_activation_charge,
|
||||
revert_trial_activation,
|
||||
rollback_trial_subscription_activation,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[Any]:
|
||||
@@ -405,10 +413,7 @@ async def show_trial_offer(
|
||||
|
||||
trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер")
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_squad_by_uuid,
|
||||
get_trial_eligible_server_squads,
|
||||
)
|
||||
from app.database.crud.server_squad import get_trial_eligible_server_squads
|
||||
|
||||
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
|
||||
|
||||
@@ -420,15 +425,6 @@ async def show_trial_offer(
|
||||
"TRIAL_SERVER_RANDOM_POOL",
|
||||
"🎲 Случайный из {count} серверов",
|
||||
).format(count=len(trial_squads))
|
||||
elif settings.TRIAL_SQUAD_UUID:
|
||||
trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID)
|
||||
if trial_server:
|
||||
trial_server_name = trial_server.display_name
|
||||
else:
|
||||
logger.warning(
|
||||
"Триальный сервер с UUID %s не найден в БД",
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
else:
|
||||
logger.warning("Не настроены сквады для выдачи триалов")
|
||||
|
||||
@@ -451,12 +447,22 @@ async def show_trial_offer(
|
||||
devices=trial_device_limit,
|
||||
)
|
||||
|
||||
price_line = ""
|
||||
if settings.is_trial_paid_activation_enabled():
|
||||
trial_price = settings.get_trial_activation_price()
|
||||
if trial_price > 0:
|
||||
price_line = texts.t(
|
||||
"TRIAL_PAYMENT_PRICE_LINE",
|
||||
"\n💳 <b>Стоимость активации:</b> {price}",
|
||||
).format(price=settings.format_price(trial_price))
|
||||
|
||||
trial_text = texts.TRIAL_AVAILABLE.format(
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB),
|
||||
devices=trial_device_limit if trial_device_limit is not None else "",
|
||||
devices_line=devices_line,
|
||||
server_name=trial_server_name
|
||||
server_name=trial_server_name,
|
||||
price_line=price_line,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -482,6 +488,30 @@ async def activate_trial(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
preview_trial_activation_charge(db_user)
|
||||
except TrialPaymentInsufficientFunds as error:
|
||||
required_label = settings.format_price(error.required_amount)
|
||||
balance_label = settings.format_price(error.balance_amount)
|
||||
missing_label = settings.format_price(error.missing_amount)
|
||||
message = texts.t(
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS",
|
||||
"⚠️ Недостаточно средств для активации триала.\n"
|
||||
"Необходимо: {required}\nНа балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\nПополните баланс и попробуйте снова.",
|
||||
).format(required=required_label, balance=balance_label, missing=missing_label)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_insufficient_balance_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
charged_amount = 0
|
||||
subscription: Optional[Subscription] = None
|
||||
remnawave_user = None
|
||||
|
||||
try:
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
@@ -495,48 +525,194 @@ async def activate_trial(
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
try:
|
||||
charged_amount = await charge_trial_activation_if_required(
|
||||
db,
|
||||
db_user,
|
||||
description="Активация триала через бота",
|
||||
)
|
||||
except TrialPaymentInsufficientFunds as error:
|
||||
rollback_success = await rollback_trial_subscription_activation(db, subscription)
|
||||
await db.refresh(db_user)
|
||||
if not rollback_success:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TRIAL_ROLLBACK_FAILED",
|
||||
"Не удалось отменить активацию триала. Попробуйте позже.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
logger.error(
|
||||
"Insufficient funds detected after trial creation for user %s: %s",
|
||||
db_user.id,
|
||||
error,
|
||||
)
|
||||
required_label = settings.format_price(error.required_amount)
|
||||
balance_label = settings.format_price(error.balance_amount)
|
||||
missing_label = settings.format_price(error.missing_amount)
|
||||
message = texts.t(
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS",
|
||||
"⚠️ Недостаточно средств для активации триала.\n"
|
||||
"Необходимо: {required}\nНа балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\nПополните баланс и попробуйте снова.",
|
||||
).format(
|
||||
required=required_label,
|
||||
balance=balance_label,
|
||||
missing=missing_label,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_insufficient_balance_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
except TrialPaymentChargeFailed:
|
||||
rollback_success = await rollback_trial_subscription_activation(db, subscription)
|
||||
await db.refresh(db_user)
|
||||
if not rollback_success:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TRIAL_ROLLBACK_FAILED",
|
||||
"Не удалось отменить активацию триала. Попробуйте позже.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TRIAL_PAYMENT_FAILED",
|
||||
"Не удалось списать средства для активации триала. Попробуйте позже.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db, subscription
|
||||
)
|
||||
try:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
)
|
||||
except RemnaWaveConfigurationError as error:
|
||||
logger.error("RemnaWave update skipped due to configuration error: %s", error)
|
||||
revert_result = await revert_trial_activation(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
charged_amount,
|
||||
refund_description="Возврат оплаты за активацию триала через бота",
|
||||
)
|
||||
if not revert_result.subscription_rolled_back:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_ROLLBACK_FAILED",
|
||||
"Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
)
|
||||
elif charged_amount > 0 and not revert_result.refunded:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_REFUND_FAILED",
|
||||
"Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
)
|
||||
else:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_PROVISIONING_FAILED",
|
||||
"Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
failure_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Failed to create RemnaWave user for trial subscription %s: %s",
|
||||
getattr(subscription, "id", "<unknown>"),
|
||||
error,
|
||||
)
|
||||
revert_result = await revert_trial_activation(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
charged_amount,
|
||||
refund_description="Возврат оплаты за активацию триала через бота",
|
||||
)
|
||||
if not revert_result.subscription_rolled_back:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_ROLLBACK_FAILED",
|
||||
"Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
)
|
||||
elif charged_amount > 0 and not revert_result.refunded:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_REFUND_FAILED",
|
||||
"Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
)
|
||||
else:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_PROVISIONING_FAILED",
|
||||
"Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
failure_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_trial_activation_notification(db, db_user, subscription)
|
||||
await notification_service.send_trial_activation_notification(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
charged_amount_kopeks=charged_amount,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о триале: {e}")
|
||||
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
payment_note = ""
|
||||
if charged_amount > 0:
|
||||
payment_note = "\n\n" + texts.t(
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE",
|
||||
"💳 С вашего баланса списано {amount}.",
|
||||
).format(amount=settings.format_price(charged_amount))
|
||||
|
||||
if remnawave_user and subscription_link:
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
trial_success_text = (
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT",
|
||||
"🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT",
|
||||
"🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
)
|
||||
elif hide_subscription_link:
|
||||
trial_success_text = (
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
|
||||
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
|
||||
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
)
|
||||
else:
|
||||
subscription_import_link = texts.t(
|
||||
@@ -550,6 +726,8 @@ async def activate_trial(
|
||||
f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
|
||||
)
|
||||
|
||||
trial_success_text += payment_note
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
@@ -560,8 +738,12 @@ async def activate_trial(
|
||||
web_app=types.WebAppInfo(url=subscription_link),
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu")],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
if not settings.MINIAPP_CUSTOM_URL:
|
||||
@@ -581,22 +763,33 @@ async def activate_trial(
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu")],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
rows = [
|
||||
[InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)]
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=subscription_link,
|
||||
)
|
||||
]
|
||||
]
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
rows.append(happ_row)
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu"
|
||||
)
|
||||
])
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
]
|
||||
)
|
||||
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
elif connect_mode == "happ_cryptolink":
|
||||
rows = [
|
||||
@@ -610,40 +803,86 @@ async def activate_trial(
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
rows.append(happ_row)
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu"
|
||||
)
|
||||
])
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
]
|
||||
)
|
||||
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
else:
|
||||
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect")],
|
||||
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu")],
|
||||
])
|
||||
connect_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
trial_success_text,
|
||||
reply_markup=connect_keyboard,
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
trial_success_text = (
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд."
|
||||
)
|
||||
trial_success_text += payment_note
|
||||
await callback.message.edit_text(
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд.",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
trial_success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
|
||||
logger.info(f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}")
|
||||
logger.info(
|
||||
f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации триала: {e}")
|
||||
failure_text = texts.ERROR
|
||||
|
||||
if subscription and remnawave_user is None:
|
||||
revert_result = await revert_trial_activation(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
charged_amount,
|
||||
refund_description="Возврат оплаты за активацию триала через бота",
|
||||
)
|
||||
if not revert_result.subscription_rolled_back:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_ROLLBACK_FAILED",
|
||||
"Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
)
|
||||
elif charged_amount > 0 and not revert_result.refunded:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_REFUND_FAILED",
|
||||
"Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
)
|
||||
else:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_PROVISIONING_FAILED",
|
||||
"Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
failure_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -1413,8 +1413,15 @@
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a trial plan{price_line}:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Devices:</b> {devices} pcs",
|
||||
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Activation price:</b> {price}",
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Not enough funds to activate the trial.\nRequired: {required}\nOn balance: {balance}\nMissing: {missing}\n\nTop up your balance and try again.",
|
||||
"TRIAL_PAYMENT_FAILED": "We couldn't charge your balance to activate the trial. Please try again later.",
|
||||
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
|
||||
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
|
||||
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
|
||||
|
||||
@@ -1433,8 +1433,15 @@
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить тестовую подписку{price_line}:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Устройства:</b> {devices} шт.",
|
||||
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Стоимость активации:</b> {price}",
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Недостаточно средств для активации триала.\nНеобходимо: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nПополните баланс и попробуйте снова.",
|
||||
"TRIAL_PAYMENT_FAILED": "Не удалось списать средства для активации триала. Попробуйте позже.",
|
||||
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
|
||||
@@ -190,7 +190,9 @@ class AdminNotificationService:
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription
|
||||
subscription: Subscription,
|
||||
*,
|
||||
charged_amount_kopeks: Optional[int] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
@@ -210,6 +212,12 @@ class AdminNotificationService:
|
||||
else:
|
||||
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
|
||||
payment_block = ""
|
||||
if charged_amount_kopeks and charged_amount_kopeks > 0:
|
||||
payment_block = (
|
||||
f"\n💳 <b>Оплата за активацию:</b> {settings.format_price(charged_amount_kopeks)}"
|
||||
)
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
@@ -224,6 +232,7 @@ class AdminNotificationService:
|
||||
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
|
||||
📱 Устройства: {trial_device_limit}
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
{payment_block}
|
||||
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
@@ -129,10 +129,7 @@ class AdvertisingCampaignService:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
getattr(settings, "TRIAL_SQUAD_UUID", None),
|
||||
)
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
@@ -141,8 +138,6 @@ class AdvertisingCampaignService:
|
||||
campaign.id,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
|
||||
@@ -162,10 +162,7 @@ class PromoCodeService:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
@@ -174,8 +171,6 @@ class PromoCodeService:
|
||||
promocode.code,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
|
||||
201
app/services/trial_activation_service.py
Normal file
201
app/services/trial_activation_service.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
from app.database.crud.user import add_user_balance, subtract_user_balance
|
||||
from app.database.models import Subscription, TransactionType, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TrialPaymentError(Exception):
|
||||
"""Base exception for trial activation payment issues."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TrialPaymentInsufficientFunds(TrialPaymentError):
|
||||
required_amount: int
|
||||
balance_amount: int
|
||||
|
||||
@property
|
||||
def missing_amount(self) -> int:
|
||||
return max(0, self.required_amount - self.balance_amount)
|
||||
|
||||
|
||||
class TrialPaymentChargeFailed(TrialPaymentError):
|
||||
"""Raised when balance charge could not be completed."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TrialActivationReversionResult:
|
||||
refunded: bool = True
|
||||
subscription_rolled_back: bool = True
|
||||
|
||||
|
||||
def get_trial_activation_charge_amount() -> int:
|
||||
"""Returns the configured activation charge in kopeks if payment is enabled."""
|
||||
|
||||
if not settings.is_trial_paid_activation_enabled():
|
||||
return 0
|
||||
|
||||
try:
|
||||
price_kopeks = int(settings.get_trial_activation_price() or 0)
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive
|
||||
price_kopeks = 0
|
||||
|
||||
return max(0, price_kopeks)
|
||||
|
||||
|
||||
def preview_trial_activation_charge(user: User) -> int:
|
||||
"""Validates that the user can afford the trial activation charge."""
|
||||
|
||||
price_kopeks = get_trial_activation_charge_amount()
|
||||
if price_kopeks <= 0:
|
||||
return 0
|
||||
|
||||
balance = int(getattr(user, "balance_kopeks", 0) or 0)
|
||||
if balance < price_kopeks:
|
||||
raise TrialPaymentInsufficientFunds(price_kopeks, balance)
|
||||
|
||||
return price_kopeks
|
||||
|
||||
|
||||
async def charge_trial_activation_if_required(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Charges the user's balance if paid trial activation is enabled.
|
||||
|
||||
Returns the charged amount in kopeks. If payment is not required or the
|
||||
configured price is zero, the function returns ``0``.
|
||||
"""
|
||||
|
||||
price_kopeks = preview_trial_activation_charge(user)
|
||||
if price_kopeks <= 0:
|
||||
return 0
|
||||
|
||||
charge_description = description or "Активация триальной подписки"
|
||||
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
price_kopeks,
|
||||
charge_description,
|
||||
)
|
||||
if not success:
|
||||
raise TrialPaymentChargeFailed()
|
||||
|
||||
# subtract_user_balance обновляет пользователя, но на всякий случай приводим к int
|
||||
return int(price_kopeks)
|
||||
|
||||
|
||||
async def refund_trial_activation_charge(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Refunds a previously charged trial activation amount back to the user."""
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
return True
|
||||
|
||||
refund_description = description or "Возврат оплаты за активацию триальной подписки"
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
refund_description,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error(
|
||||
"Failed to refund %s kopeks for user %s during trial activation rollback",
|
||||
amount_kopeks,
|
||||
getattr(user, "id", "<unknown>"),
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
async def rollback_trial_subscription_activation(
|
||||
db: AsyncSession,
|
||||
subscription: Optional[Subscription],
|
||||
) -> bool:
|
||||
"""Attempts to undo a previously created trial subscription.
|
||||
|
||||
Returns ``True`` when the rollback succeeds or when ``subscription`` is
|
||||
falsy. In case of a database failure the function returns ``False`` after
|
||||
logging the error so callers can decide how to proceed.
|
||||
"""
|
||||
|
||||
if not subscription:
|
||||
return True
|
||||
|
||||
try:
|
||||
await decrement_subscription_server_counts(db, subscription)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Failed to decrement server counters during trial rollback for %s: %s",
|
||||
subscription.user_id,
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.delete(subscription)
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Failed to remove trial subscription %s after charge failure: %s",
|
||||
getattr(subscription, "id", "<unknown>"),
|
||||
error,
|
||||
)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def revert_trial_activation(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Optional[Subscription],
|
||||
charged_amount: int,
|
||||
*,
|
||||
refund_description: Optional[str] = None,
|
||||
) -> TrialActivationReversionResult:
|
||||
"""Rolls back a trial subscription and refunds any charged amount."""
|
||||
|
||||
rollback_success = await rollback_trial_subscription_activation(db, subscription)
|
||||
refund_success = await refund_trial_activation_charge(
|
||||
db,
|
||||
user,
|
||||
charged_amount,
|
||||
description=refund_description,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.refresh(user)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to refresh user %s after reverting trial activation: %s",
|
||||
getattr(user, "id", "<unknown>"),
|
||||
error,
|
||||
)
|
||||
|
||||
return TrialActivationReversionResult(
|
||||
refunded=refund_success,
|
||||
subscription_rolled_back=rollback_success,
|
||||
)
|
||||
@@ -67,6 +67,14 @@ from app.services.payment_service import PaymentService, get_wata_payment_by_lin
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.trial_activation_service import (
|
||||
TrialPaymentChargeFailed,
|
||||
TrialPaymentInsufficientFunds,
|
||||
charge_trial_activation_if_required,
|
||||
preview_trial_activation_charge,
|
||||
revert_trial_activation,
|
||||
rollback_trial_subscription_activation,
|
||||
)
|
||||
from app.services.subscription_purchase_service import (
|
||||
purchase_service,
|
||||
PurchaseBalanceError,
|
||||
@@ -2888,6 +2896,13 @@ async def get_subscription_details(
|
||||
trial_duration_days = (
|
||||
settings.TRIAL_DURATION_DAYS if settings.TRIAL_DURATION_DAYS > 0 else None
|
||||
)
|
||||
trial_price_kopeks = settings.get_trial_activation_price()
|
||||
trial_payment_required = (
|
||||
settings.is_trial_paid_activation_enabled() and trial_price_kopeks > 0
|
||||
)
|
||||
trial_price_label = (
|
||||
settings.format_price(trial_price_kopeks) if trial_payment_required else None
|
||||
)
|
||||
|
||||
subscription_missing_reason = None
|
||||
if subscription is None:
|
||||
@@ -2951,6 +2966,9 @@ async def get_subscription_details(
|
||||
trial_available=trial_available,
|
||||
trial_duration_days=trial_duration_days,
|
||||
trial_status="available" if trial_available else "unavailable",
|
||||
trial_payment_required=trial_payment_required,
|
||||
trial_price_kopeks=trial_price_kopeks if trial_payment_required else None,
|
||||
trial_price_label=trial_price_label,
|
||||
**autopay_extras,
|
||||
)
|
||||
|
||||
@@ -3103,6 +3121,20 @@ async def activate_subscription_trial_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
preview_trial_activation_charge(user)
|
||||
except TrialPaymentInsufficientFunds as error:
|
||||
missing = error.missing_amount
|
||||
raise HTTPException(
|
||||
status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"code": "insufficient_funds",
|
||||
"message": "Not enough funds to activate the trial",
|
||||
"missing_amount_kopeks": missing,
|
||||
"required_amount_kopeks": error.required_amount,
|
||||
"balance_kopeks": error.balance_amount,
|
||||
},
|
||||
) from error
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
@@ -3127,6 +3159,61 @@ async def activate_subscription_trial_endpoint(
|
||||
},
|
||||
) from error
|
||||
|
||||
charged_amount = 0
|
||||
try:
|
||||
charged_amount = await charge_trial_activation_if_required(db, user)
|
||||
except TrialPaymentInsufficientFunds as error:
|
||||
rollback_success = await rollback_trial_subscription_activation(db, subscription)
|
||||
await db.refresh(user)
|
||||
if not rollback_success:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_rollback_failed",
|
||||
"message": "Failed to revert trial activation after charge error",
|
||||
},
|
||||
) from error
|
||||
|
||||
logger.error(
|
||||
"Balance check failed after trial creation for user %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"code": "insufficient_funds",
|
||||
"message": "Not enough funds to activate the trial",
|
||||
"missing_amount_kopeks": error.missing_amount,
|
||||
"required_amount_kopeks": error.required_amount,
|
||||
"balance_kopeks": error.balance_amount,
|
||||
},
|
||||
) from error
|
||||
except TrialPaymentChargeFailed as error:
|
||||
rollback_success = await rollback_trial_subscription_activation(db, subscription)
|
||||
await db.refresh(user)
|
||||
if not rollback_success:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_rollback_failed",
|
||||
"message": "Failed to revert trial activation after charge error",
|
||||
},
|
||||
) from error
|
||||
|
||||
logger.error(
|
||||
"Failed to charge balance for trial activation after subscription %s creation: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "charge_failed",
|
||||
"message": "Failed to charge balance for trial activation",
|
||||
},
|
||||
) from error
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -3134,13 +3221,75 @@ async def activate_subscription_trial_endpoint(
|
||||
try:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues
|
||||
logger.warning("RemnaWave update skipped: %s", error)
|
||||
logger.error("RemnaWave update skipped due to configuration error: %s", error)
|
||||
revert_result = await revert_trial_activation(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
charged_amount,
|
||||
refund_description="Возврат оплаты за активацию триала в мини-приложении",
|
||||
)
|
||||
if not revert_result.subscription_rolled_back:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_rollback_failed",
|
||||
"message": "Failed to revert trial activation after RemnaWave error",
|
||||
},
|
||||
) from error
|
||||
if charged_amount > 0 and not revert_result.refunded:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_refund_failed",
|
||||
"message": "Failed to refund trial activation charge after RemnaWave error",
|
||||
},
|
||||
) from error
|
||||
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail={
|
||||
"code": "remnawave_configuration_error",
|
||||
"message": "Trial activation failed due to RemnaWave configuration. Charge refunded.",
|
||||
},
|
||||
) from error
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Failed to create RemnaWave user for trial subscription %s: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
revert_result = await revert_trial_activation(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
charged_amount,
|
||||
refund_description="Возврат оплаты за активацию триала в мини-приложении",
|
||||
)
|
||||
if not revert_result.subscription_rolled_back:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_rollback_failed",
|
||||
"message": "Failed to revert trial activation after RemnaWave error",
|
||||
},
|
||||
) from error
|
||||
if charged_amount > 0 and not revert_result.refunded:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_refund_failed",
|
||||
"message": "Failed to refund trial activation charge after RemnaWave error",
|
||||
},
|
||||
) from error
|
||||
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail={
|
||||
"code": "remnawave_provisioning_failed",
|
||||
"message": "Trial activation failed due to RemnaWave provisioning. Charge refunded.",
|
||||
},
|
||||
) from error
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -3158,6 +3307,9 @@ async def activate_subscription_trial_endpoint(
|
||||
duration_days = settings.TRIAL_DURATION_DAYS
|
||||
|
||||
language_code = _normalize_language_code(user)
|
||||
charged_amount_label = (
|
||||
settings.format_price(charged_amount) if charged_amount > 0 else None
|
||||
)
|
||||
if language_code == "ru":
|
||||
if duration_days:
|
||||
message = f"Триал активирован на {duration_days} дн. Приятного пользования!"
|
||||
@@ -3169,8 +3321,19 @@ async def activate_subscription_trial_endpoint(
|
||||
else:
|
||||
message = "Trial activated successfully. Enjoy!"
|
||||
|
||||
if charged_amount_label:
|
||||
if language_code == "ru":
|
||||
message = f"{message}\n\n💳 С вашего баланса списано {charged_amount_label}."
|
||||
else:
|
||||
message = f"{message}\n\n💳 {charged_amount_label} has been deducted from your balance."
|
||||
|
||||
await _with_admin_notification_service(
|
||||
lambda service: service.send_trial_activation_notification(db, user, subscription)
|
||||
lambda service: service.send_trial_activation_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
charged_amount_kopeks=charged_amount,
|
||||
)
|
||||
)
|
||||
|
||||
return MiniAppSubscriptionTrialResponse(
|
||||
@@ -3178,6 +3341,10 @@ async def activate_subscription_trial_endpoint(
|
||||
subscription_id=getattr(subscription, "id", None),
|
||||
trial_status="activated",
|
||||
trial_duration_days=duration_days,
|
||||
charged_amount_kopeks=charged_amount if charged_amount > 0 else None,
|
||||
charged_amount_label=charged_amount_label,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_label=settings.format_price(user.balance_kopeks),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -465,8 +465,11 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
trial_available: bool = False
|
||||
trial_duration_days: Optional[int] = None
|
||||
trial_status: Optional[str] = None
|
||||
trial_payment_required: bool = Field(default=False, alias="trialPaymentRequired")
|
||||
trial_price_kopeks: Optional[int] = Field(default=None, alias="trialPriceKopeks")
|
||||
trial_price_label: Optional[str] = Field(default=None, alias="trialPriceLabel")
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionServerOption(BaseModel):
|
||||
@@ -736,6 +739,10 @@ class MiniAppSubscriptionTrialResponse(BaseModel):
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
trial_status: Optional[str] = Field(default=None, alias="trialStatus")
|
||||
trial_duration_days: Optional[int] = Field(default=None, alias="trialDurationDays")
|
||||
charged_amount_kopeks: Optional[int] = Field(default=None, alias="chargedAmountKopeks")
|
||||
charged_amount_label: Optional[str] = Field(default=None, alias="chargedAmountLabel")
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user