Handle trial provisioning failures by refunding charges

This commit is contained in:
Egor
2025-11-08 08:14:49 +03:00
parent cb6d51ed3d
commit cd3832fef0
5 changed files with 247 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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