mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Handle trial provisioning failures by refunding charges
This commit is contained in:
@@ -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,
|
||||
@@ -62,8 +62,9 @@ from app.services.trial_activation_service import (
|
||||
TrialPaymentChargeFailed,
|
||||
TrialPaymentInsufficientFunds,
|
||||
charge_trial_activation_if_required,
|
||||
rollback_trial_subscription_activation,
|
||||
preview_trial_activation_charge,
|
||||
revert_trial_activation,
|
||||
rollback_trial_subscription_activation,
|
||||
)
|
||||
|
||||
|
||||
@@ -508,6 +509,8 @@ async def activate_trial(
|
||||
return
|
||||
|
||||
charged_amount = 0
|
||||
subscription: Optional[Subscription] = None
|
||||
remnawave_user = None
|
||||
|
||||
try:
|
||||
forced_devices = None
|
||||
@@ -589,9 +592,77 @@ async def activate_trial(
|
||||
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)
|
||||
|
||||
@@ -780,10 +851,38 @@ async def activate_trial(
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -1418,7 +1418,9 @@
|
||||
"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",
|
||||
|
||||
@@ -1438,7 +1438,9 @@
|
||||
"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",
|
||||
|
||||
@@ -8,8 +8,8 @@ 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 subtract_user_balance
|
||||
from app.database.models import Subscription, User
|
||||
from app.database.crud.user import add_user_balance, subtract_user_balance
|
||||
from app.database.models import Subscription, TransactionType, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,6 +33,12 @@ 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."""
|
||||
|
||||
@@ -92,6 +98,38 @@ async def charge_trial_activation_if_required(
|
||||
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],
|
||||
@@ -128,3 +166,36 @@ async def rollback_trial_subscription_activation(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -71,8 +71,9 @@ from app.services.trial_activation_service import (
|
||||
TrialPaymentChargeFailed,
|
||||
TrialPaymentInsufficientFunds,
|
||||
charge_trial_activation_if_required,
|
||||
rollback_trial_subscription_activation,
|
||||
preview_trial_activation_charge,
|
||||
revert_trial_activation,
|
||||
rollback_trial_subscription_activation,
|
||||
)
|
||||
from app.services.subscription_purchase_service import (
|
||||
purchase_service,
|
||||
@@ -3220,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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user