From afefcc9c07a80ecf65871bc2d0fbcfe13ffc24fd Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 02:10:41 +0300 Subject: [PATCH] fix: resolve remaining TOCTOU issues in RioPay, SeverPay and restore paid_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RioPay: use create_transaction(commit=False) to keep FOR UPDATE lock, replace update_riopay_payment_status with inline assignment + flush, add emit_transaction_side_effects after commit - SeverPay: add db.flush() before _finalize, remove self-assignment, add paid_at to both webhook and status-check paths - Freekassa/KassaAI: add is_paid and paid_at to webhook and status-check inline sections (regression from CRUD→inline migration) - MulenPay: add is_paid and paid_at to webhook inline section --- app/services/payment/freekassa.py | 4 ++++ app/services/payment/kassa_ai.py | 4 ++++ app/services/payment/mulenpay.py | 2 ++ app/services/payment/riopay.py | 30 ++++++++++++++++++++++-------- app/services/payment/severpay.py | 4 +++- 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index 848ddbd9..6374b895 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -229,6 +229,8 @@ class FreekassaPaymentMixin: 'cur_id': cur_id, } payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.callback_payload = callback_payload payment.freekassa_order_id = intid if cur_id is not None: @@ -529,6 +531,8 @@ class FreekassaPaymentMixin: # Inline field updates — NO intermediate commit that would release FOR UPDATE lock payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.callback_payload = callback_payload payment.freekassa_order_id = fk_intid if target_order.get('curID'): diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index 907019aa..c0821bd1 100644 --- a/app/services/payment/kassa_ai.py +++ b/app/services/payment/kassa_ai.py @@ -222,6 +222,8 @@ class KassaAiPaymentMixin: 'cur_id': cur_id, } payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.callback_payload = callback_payload payment.kassa_ai_order_id = intid if cur_id is not None: @@ -508,6 +510,8 @@ class KassaAiPaymentMixin: # Inline field updates — NO intermediate commit that would release FOR UPDATE lock payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.callback_payload = callback_payload payment.kassa_ai_order_id = kai_intid if target_order.get('curID'): diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 2988f856..be08197f 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -225,6 +225,8 @@ class MulenPayPaymentMixin: if payment_status == 'success': # Inline field updates — NO intermediate commit that would release FOR UPDATE lock payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.callback_payload = callback_data if mulen_payment_id_int is not None and not payment.mulen_payment_id: payment.mulen_payment_id = mulen_payment_id_int diff --git a/app/services/payment/riopay.py b/app/services/payment/riopay.py index 3c0b70a6..ac008e49 100644 --- a/app/services/payment/riopay.py +++ b/app/services/payment/riopay.py @@ -323,7 +323,7 @@ class RioPayPaymentMixin: ) return False - # Создаем транзакцию + # Создаем транзакцию (commit=False to keep FOR UPDATE lock intact) transaction = await create_transaction( db, user_id=payment.user_id, @@ -334,15 +334,13 @@ class RioPayPaymentMixin: external_id=str(riopay_order_id) if riopay_order_id else payment.order_id, is_completed=True, created_at=getattr(payment, 'created_at', None), + commit=False, ) - # Связываем платеж с транзакцией - await update_riopay_payment_status( - db=db, - payment=payment, - status=payment.status, - transaction_id=transaction.id, - ) + # Связываем платеж с транзакцией (inline — no commit to preserve lock) + payment.transaction_id = transaction.id + payment.updated_at = datetime.now(UTC) + await db.flush() old_balance = user.balance_kopeks was_first_topup = not user.has_made_first_topup @@ -365,6 +363,22 @@ class RioPayPaymentMixin: await db.commit() + # Emit deferred side-effects after atomic commit (events, promo group checks) + try: + from app.database.crud.transaction import emit_transaction_side_effects + + await emit_transaction_side_effects( + db, + transaction, + amount_kopeks=payment.amount_kopeks, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + payment_method=PaymentMethod.RIOPAY, + external_id=str(riopay_order_id) if riopay_order_id else payment.order_id, + ) + except Exception as error: + logger.error('Ошибка emit_transaction_side_effects RioPay', error=error) + # Обработка реферального пополнения try: from app.services.referral_service import process_referral_topup diff --git a/app/services/payment/severpay.py b/app/services/payment/severpay.py index a3941636..50630b9e 100644 --- a/app/services/payment/severpay.py +++ b/app/services/payment/severpay.py @@ -266,9 +266,11 @@ class SeverPayPaymentMixin: # Inline field assignments to keep FOR UPDATE lock intact payment.status = internal_status payment.is_paid = True + payment.paid_at = datetime.now(UTC) payment.severpay_id = severpay_id or payment.severpay_id payment.callback_payload = callback_payload payment.updated_at = datetime.now(UTC) + await db.flush() return await self._finalize_severpay_payment(db, payment, severpay_id=severpay_id, trigger='webhook') # Для не-success статусов можно безопасно коммитить @@ -581,7 +583,7 @@ class SeverPayPaymentMixin: # Inline field updates — NO intermediate commit that would release FOR UPDATE lock payment.status = 'success' payment.is_paid = True - payment.severpay_id = payment.severpay_id + payment.paid_at = datetime.now(UTC) payment.callback_payload = { 'check_source': 'api', 'severpay_order_data': order_data,