diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 1fd78488..9b658d78 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -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", ""), + 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() diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index e1f95605..a47b835c 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1418,7 +1418,9 @@ "TRIAL_PAYMENT_PRICE_LINE": "\n💳 Activation price: {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🚫 Access paused\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🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\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", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index c0afb67e..f8343def 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1438,7 +1438,9 @@ "TRIAL_PAYMENT_PRICE_LINE": "\n💳 Стоимость активации: {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🚫 Доступ приостановлен\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.", "TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n", diff --git a/app/services/trial_activation_service.py b/app/services/trial_activation_service.py index 635a9611..e335c5a2 100644 --- a/app/services/trial_activation_service.py +++ b/app/services/trial_activation_service.py @@ -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", ""), + ) + + 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", ""), + error, + ) + + return TrialActivationReversionResult( + refunded=refund_success, + subscription_rolled_back=rollback_success, + ) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index cdd49c65..9f899b27 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -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)