diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 41336a9a..f60dcc89 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -38,7 +38,6 @@ from app.services.user_cart_service import user_cart_service from app.utils.cache import RateLimitCache, cache, cache_key from app.utils.pricing_utils import format_period_description from app.utils.promo_offer import get_user_active_promo_discount_percent -from app.utils.user_utils import mark_user_as_had_paid_subscription from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.subscription import ( @@ -535,14 +534,31 @@ async def renew_subscription( }, ) - # Deduct balance and extend subscription - user.balance_kopeks -= price_kopeks + # Deduct balance with row-level lock (prevents concurrent race conditions) + from sqlalchemy import select as sa_select + + locked_result = await db.execute(sa_select(User).where(User.id == user.id).with_for_update()) + user_locked = locked_result.scalar_one() + + if user_locked.balance_kopeks < price_kopeks: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + 'code': 'insufficient_funds', + 'message': 'Недостаточно средств (concurrent check)', + }, + ) + + user_locked.balance_kopeks -= price_kopeks # Consume promo offer discount if it was used if promo_offer_discount_value > 0: - user.promo_offer_discount_percent = 0 - user.promo_offer_discount_source = None - user.promo_offer_discount_expires_at = None + user_locked.promo_offer_discount_percent = 0 + user_locked.promo_offer_discount_source = None + user_locked.promo_offer_discount_expires_at = None + + # Mark user as having had a paid subscription (prevents first_purchase_only promo reuse) + user_locked.has_had_paid_subscription = True # Extend from end_date or now if expired now = datetime.now(UTC) @@ -582,9 +598,6 @@ async def renew_subscription( await db.commit() - # Mark user as having had a paid subscription (prevents first_purchase_only promo reuse) - await mark_user_as_had_paid_subscription(db, user) - # Синхронизируем с RemnaWave try: subscription_service = SubscriptionService() @@ -2057,19 +2070,20 @@ async def purchase_tariff( description += f' (скидка {discount_percent}%)' if promo_offer_discount_value > 0: description += f' (промо -{promo_offer_discount_percent}%)' - success = await subtract_user_balance(db, user, price_kopeks, description) + success = await subtract_user_balance( + db, + user, + price_kopeks, + description, + consume_promo_offer=promo_offer_discount_value > 0, + mark_as_paid_subscription=True, + ) if not success: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail='Failed to charge balance', ) - # Consume promo offer discount if it was used - if promo_offer_discount_value > 0: - user.promo_offer_discount_percent = 0 - user.promo_offer_discount_source = None - user.promo_offer_discount_expires_at = None - # Create transaction await create_transaction( db=db, @@ -2130,9 +2144,6 @@ async def purchase_tariff( except Exception as remnawave_error: logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error) - # Mark user as having had a paid subscription (prevents first_purchase_only promo reuse) - await mark_user_as_had_paid_subscription(db, user) - # Save cart for auto-renewal (not for daily tariffs - they have their own charging) if not is_daily_tariff: try: @@ -4157,7 +4168,10 @@ async def switch_tariff( if period_discount_percent > 0 and discount_value > 0: description += f' (скидка {period_discount_percent}%)' - success = await subtract_user_balance(db, user, upgrade_cost, description) + success = await subtract_user_balance( + db, user, upgrade_cost, description, + mark_as_paid_subscription=True, + ) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 6ae2e1da..9170471a 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -505,6 +505,7 @@ async def subtract_user_balance( payment_method: PaymentMethod | None = None, *, consume_promo_offer: bool = False, + mark_as_paid_subscription: bool = False, ) -> bool: user_id_display = user.telegram_id or user.email or f'#{user.id}' logger.info('💸 ОТЛАДКА subtract_user_balance:') @@ -564,6 +565,9 @@ async def subtract_user_balance( user.promo_offer_discount_source = None user.promo_offer_discount_expires_at = None + if mark_as_paid_subscription: + user.has_had_paid_subscription = True + user.updated_at = datetime.now(UTC) if create_transaction: diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 4595188b..e704aa15 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -1349,7 +1349,10 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d ) # Списать баланс правильно - await subtract_user_balance(db, db_user, best_price, f'Активация подписки на {best_period} дней') + await subtract_user_balance( + db, db_user, best_price, f'Активация подписки на {best_period} дней', + mark_as_paid_subscription=True, + ) # Создать пользователя в RemnaWave await subscription_service.create_remnawave_user(db, new_subscription) diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index c010a6ad..2983c1f8 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -439,6 +439,7 @@ async def handle_simple_subscription_pay_with_balance( price_kopeks, f'Оплата подписки на {subscription_params["period_days"]} дней', consume_promo_offer=False, + mark_as_paid_subscription=True, ) if not success: @@ -2143,6 +2144,7 @@ async def confirm_simple_subscription_purchase( price_kopeks, f'Оплата подписки на {subscription_params["period_days"]} дней', consume_promo_offer=False, + mark_as_paid_subscription=True, ) if not success: diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index ade6b142..dd221af1 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1960,6 +1960,7 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us price, f'Продление подписки на {days} дней', consume_promo_offer=promo_component['discount'] > 0, + mark_as_paid_subscription=True, ) if not success: @@ -2578,6 +2579,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ final_price, f'Покупка подписки на {data["period_days"]} дней', consume_promo_offer=promo_offer_discount_value > 0, + mark_as_paid_subscription=True, ) if not success: @@ -2748,10 +2750,6 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ traffic_gb=final_traffic_gb, ) - from app.utils.user_utils import mark_user_as_had_paid_subscription - - await mark_user_as_had_paid_subscription(db, db_user) - from app.database.crud.server_squad import add_user_to_servers, get_server_ids_by_uuids from app.database.crud.subscription import add_subscription_servers @@ -3282,6 +3280,7 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user: db_user, trial_price_kopeks, texts.t('TRIAL_PAYMENT_DESCRIPTION', 'Оплата пробной подписки'), + mark_as_paid_subscription=True, ) if not success: @@ -4428,6 +4427,7 @@ async def _extend_existing_subscription( price_kopeks, f'Продление подписки на {period_days} дней', consume_promo_offer=False, # Простая покупка не использует промо-скидки + mark_as_paid_subscription=True, ) if not success: diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 0e1987e3..21375261 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -821,7 +821,8 @@ async def handle_custom_confirm( try: # Списываем баланс success = await subtract_user_balance( - db, db_user, total_price, f'Покупка тарифа {tariff.name} на {custom_days} дней' + db, db_user, total_price, f'Покупка тарифа {tariff.name} на {custom_days} дней', + mark_as_paid_subscription=True, ) if not success: await callback.answer('Ошибка списания баланса', show_alert=True) @@ -1131,7 +1132,8 @@ async def confirm_tariff_purchase( try: # Списываем баланс success = await subtract_user_balance( - db, db_user, final_price, f'Покупка тарифа {tariff.name} на {period} дней' + db, db_user, final_price, f'Покупка тарифа {tariff.name} на {period} дней', + mark_as_paid_subscription=True, ) if not success: await callback.answer('Ошибка списания баланса', show_alert=True) @@ -1289,7 +1291,8 @@ async def confirm_daily_tariff_purchase( try: # Списываем первый день сразу success = await subtract_user_balance( - db, db_user, daily_price, f'Покупка суточного тарифа {tariff.name} (первый день)' + db, db_user, daily_price, f'Покупка суточного тарифа {tariff.name} (первый день)', + mark_as_paid_subscription=True, ) if not success: await callback.answer('Ошибка списания баланса', show_alert=True) @@ -1701,7 +1704,8 @@ async def confirm_tariff_extend( try: # Списываем баланс success = await subtract_user_balance( - db, db_user, final_price, f'Продление тарифа {tariff.name} на {period} дней' + db, db_user, final_price, f'Продление тарифа {tariff.name} на {period} дней', + mark_as_paid_subscription=True, ) if not success: await callback.answer('Ошибка списания баланса', show_alert=True) @@ -2232,7 +2236,8 @@ async def confirm_tariff_switch( try: # Списываем баланс success = await subtract_user_balance( - db, db_user, final_price, f'Смена тарифа на {tariff.name} ({period} дней)' + db, db_user, final_price, f'Смена тарифа на {tariff.name} ({period} дней)', + mark_as_paid_subscription=True, ) if not success: await callback.answer('Ошибка списания баланса', show_alert=True) @@ -2401,7 +2406,8 @@ async def confirm_daily_tariff_switch( try: # Списываем первый день сразу success = await subtract_user_balance( - db, db_user, daily_price, f'Смена на суточный тариф {tariff.name} (первый день)' + db, db_user, daily_price, f'Смена на суточный тариф {tariff.name} (первый день)', + mark_as_paid_subscription=True, ) if not success: await callback.answer('Ошибка списания баланса', show_alert=True) @@ -2962,7 +2968,10 @@ async def confirm_instant_switch( try: # Списываем баланс если это upgrade if is_upgrade and upgrade_cost > 0: - success = await subtract_user_balance(db, db_user, upgrade_cost, f'Переключение на тариф {new_tariff.name}') + success = await subtract_user_balance( + db, db_user, upgrade_cost, f'Переключение на тариф {new_tariff.name}', + mark_as_paid_subscription=True, + ) if not success: await callback.answer('Ошибка списания баланса', show_alert=True) return @@ -3011,7 +3020,8 @@ async def confirm_instant_switch( if upgrade_cost == 0 and daily_price > 0: if user_balance >= daily_price: await subtract_user_balance( - db, db_user, daily_price, f'Переключение на суточный тариф {new_tariff.name} (первый день)' + db, db_user, daily_price, f'Переключение на суточный тариф {new_tariff.name} (первый день)', + mark_as_paid_subscription=True, ) await create_transaction( db, diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 9811f20b..745e8524 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1124,7 +1124,10 @@ class MonitoringService: continue if user.balance_kopeks >= charge_amount: - success = await subtract_user_balance(db, user, charge_amount, 'Автопродление подписки') + success = await subtract_user_balance( + db, user, charge_amount, 'Автопродление подписки', + mark_as_paid_subscription=True, + ) if success: # extend_subscription сам обработает EXPIRED→ACTIVE переход diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 47f6865b..3af0c0be 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -30,7 +30,6 @@ from app.services.subscription_service import SubscriptionService from app.services.user_cart_service import user_cart_service from app.utils.pricing_utils import format_period_description from app.utils.timezone import format_local_datetime -from app.utils.user_utils import mark_user_as_had_paid_subscription logger = structlog.get_logger(__name__) @@ -398,6 +397,7 @@ async def _auto_extend_subscription( prepared.price_kopeks, prepared.description, consume_promo_offer=prepared.consume_promo_offer, + mark_as_paid_subscription=True, ) except Exception as error: # pragma: no cover - defensive logging logger.error( @@ -496,9 +496,6 @@ async def _auto_extend_subscription( error=error, ) - # Mark user as having had a paid subscription (prevents first_purchase_only promo reuse) - await mark_user_as_had_paid_subscription(db, user) - await user_cart_service.delete_user_cart(user.id) await clear_subscription_checkout_draft(user.id) @@ -702,7 +699,14 @@ async def _auto_purchase_tariff( # Списываем баланс try: description = f'Покупка тарифа {tariff.name} на {period_days} дней' - success = await subtract_user_balance(db, user, final_price, description) + success = await subtract_user_balance( + db, + user, + final_price, + description, + consume_promo_offer=promo_offer_percent > 0, + mark_as_paid_subscription=True, + ) if not success: logger.warning( '❌ Автопокупка тарифа: не удалось списать баланс пользователя', format_user_id=_format_user_id(user) @@ -966,7 +970,13 @@ async def _auto_purchase_daily_tariff( # Списываем баланс за первый день try: description = f'Активация суточного тарифа {tariff.name}' - success = await subtract_user_balance(db, user, daily_price, description) + success = await subtract_user_balance( + db, + user, + daily_price, + description, + mark_as_paid_subscription=True, + ) if not success: logger.warning( '❌ Автопокупка суточного тарифа: не удалось списать баланс пользователя', diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index e74968da..e4f89862 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -32,7 +32,6 @@ from app.utils.pricing_utils import ( validate_pricing_calculation, ) from app.utils.promo_offer import get_user_active_promo_discount_percent -from app.utils.user_utils import mark_user_as_had_paid_subscription logger = structlog.get_logger(__name__) @@ -1036,6 +1035,7 @@ class MiniAppSubscriptionPurchaseService: pricing.final_total, description, consume_promo_offer=pricing.promo_discount_value > 0, + mark_as_paid_subscription=True, ) if not success: raise PurchaseBalanceError( @@ -1116,8 +1116,6 @@ class MiniAppSubscriptionPurchaseService: update_server_counters=False, ) - await mark_user_as_had_paid_subscription(db, user) - if pricing.server_ids: try: await add_subscription_servers( diff --git a/app/services/subscription_renewal_service.py b/app/services/subscription_renewal_service.py index baa006d1..52278a59 100644 --- a/app/services/subscription_renewal_service.py +++ b/app/services/subscription_renewal_service.py @@ -473,6 +473,7 @@ class SubscriptionRenewalService: charge_from_balance, description_text, consume_promo_offer=consume_promo_offer, + mark_as_paid_subscription=True, ) if not success: raise SubscriptionRenewalChargeError('Failed to charge balance') diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 85384c66..4c6425d3 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -5326,7 +5326,10 @@ async def submit_subscription_renewal_endpoint( try: # Списываем баланс (subtract_user_balance делает commit и обновляет user.balance_kopeks) - success = await subtract_user_balance(db, user, final_total, description) + success = await subtract_user_balance( + db, user, final_total, description, + mark_as_paid_subscription=True, + ) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -6570,7 +6573,10 @@ async def purchase_tariff_endpoint( description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней (скидка {discount_percent}%)" else: description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней" - success = await subtract_user_balance(db, user, price_kopeks, description) + success = await subtract_user_balance( + db, user, price_kopeks, description, + mark_as_paid_subscription=True, + ) if not success: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, @@ -6948,7 +6954,10 @@ async def switch_tariff_endpoint( description = f"Переход с суточного на тариф '{new_tariff.name}' ({new_period_days} дней)" else: description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)" - success = await subtract_user_balance(db, user, upgrade_cost, description) + success = await subtract_user_balance( + db, user, upgrade_cost, description, + mark_as_paid_subscription=True, + ) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,