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:
Fringg
2026-03-05 06:29:34 +03:00
parent 2cec8dc4a4
commit e4a6aad621
11 changed files with 100 additions and 46 deletions
+34 -20
View File
@@ -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,
+4
View File
@@ -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:
+4 -1
View File
@@ -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)
+2
View File
@@ -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:
+4 -4
View File
@@ -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:
+18 -8
View File
@@ -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,
+4 -1
View File
@@ -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')
+12 -3
View File
@@ -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,