fix: centralize has_had_paid_subscription into subtract_user_balance
Add mark_as_paid_subscription parameter to subtract_user_balance() that atomically sets has_had_paid_subscription=True within the same FOR UPDATE transaction as the balance deduction. This closes ALL purchase paths: - Cabinet renew: add SELECT FOR UPDATE row lock (fix race condition), set has_had_paid_subscription atomically, remove standalone call - Cabinet purchase_tariff: pass consume_promo_offer to subtract_user_balance (fix: inline clearing was wiped by db.refresh), remove standalone call - Cabinet switch_tariff: add mark_as_paid_subscription=True - Auto-extend: add mark_as_paid_subscription, remove standalone call - Auto-purchase tariff: add consume_promo_offer + mark_as_paid_subscription - Auto-purchase daily: add mark_as_paid_subscription - Bot purchase/extend/trial handlers: add mark_as_paid_subscription - All 8 tariff_purchase.py handlers: add mark_as_paid_subscription - Both simple_subscription.py handlers: add mark_as_paid_subscription - Menu smart activation: add mark_as_paid_subscription - Monitoring autopay: add mark_as_paid_subscription - Renewal service finalize: add mark_as_paid_subscription - MiniApp purchase service: add mark_as_paid_subscription, remove standalone - MiniApp renewal/tariff/switch: add mark_as_paid_subscription
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 переход
|
||||
|
||||
@@ -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(
|
||||
'❌ Автопокупка суточного тарифа: не удалось списать баланс пользователя',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user