From 1a54beed1728f6c433f6f5a84b1188b6a00838b1 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 31 Oct 2025 21:07:48 +0300 Subject: [PATCH] Fix duplicate MulenPay transactions in history --- app/services/payment/mulenpay.py | 1 + .../services/test_payment_service_mulenpay.py | 164 ++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index f142440a..951b7ee7 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -247,6 +247,7 @@ class MulenPayPaymentMixin: user, payment.amount_kopeks, f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽", + create_transaction=False, ) try: diff --git a/tests/services/test_payment_service_mulenpay.py b/tests/services/test_payment_service_mulenpay.py index 61f7fec8..ba4c757c 100644 --- a/tests/services/test_payment_service_mulenpay.py +++ b/tests/services/test_payment_service_mulenpay.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import Any, Dict, Optional +from types import ModuleType, SimpleNamespace import sys from datetime import datetime @@ -25,6 +26,9 @@ class DummySession: async def commit(self) -> None: # pragma: no cover - метод вызывается, но без логики return None + async def refresh(self, *_args: Any, **_kwargs: Any) -> None: + return None + class DummyLocalPayment: def __init__(self, payment_id: int = 501) -> None: @@ -139,3 +143,163 @@ async def test_create_mulenpay_payment_returns_none_without_service() -> None: description="Пополнение", ) assert result is None + + +@pytest.mark.anyio("asyncio") +async def test_process_mulenpay_callback_avoids_duplicate_transactions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = _make_service(None) + db = DummySession() + + class DummyPayment: + def __init__(self) -> None: + self.user_id = 42 + self.amount_kopeks = 1500 + self.description = "Пополнение" + self.uuid = "mulen_1_test" + self.transaction_id: Optional[int] = None + self.mulen_payment_id: Optional[int] = None + self.status = "created" + self.is_paid = False + + payment = DummyPayment() + + async def fake_get_mulenpay_payment_by_uuid( + _db: DummySession, uuid: str + ) -> DummyPayment: + assert uuid == payment.uuid + return payment + + async def fake_update_mulenpay_payment_status( + _db: DummySession, **kwargs: Any + ) -> DummyPayment: + payment.status = kwargs.get("status", payment.status) + payment.mulen_payment_id = kwargs.get("mulen_payment_id", payment.mulen_payment_id) + return payment + + transaction_calls: list[Dict[str, Any]] = [] + + class DummyTransaction: + def __init__(self, transaction_id: int = 555) -> None: + self.id = transaction_id + + async def fake_create_transaction(_db: DummySession, **kwargs: Any) -> DummyTransaction: + transaction_calls.append(kwargs) + return DummyTransaction() + + async def fake_link_payment( + db: DummySession, *, payment: DummyPayment, transaction_id: int + ) -> DummyPayment: + payment.transaction_id = transaction_id + return payment + + class DummyUser: + def __init__(self) -> None: + self.id = payment.user_id + self.telegram_id = 99 + self.balance_kopeks = 0 + self.has_made_first_topup = False + self.language = "ru" + self.promo_group = None + self.subscription = None + + dummy_user = DummyUser() + + async def fake_get_user_by_id(_db: DummySession, user_id: int) -> DummyUser: + assert user_id == payment.user_id + return dummy_user + + balance_call: Dict[str, Any] = {} + + async def fake_add_user_balance( + _db: DummySession, + user: DummyUser, + amount_kopeks: int, + description: str, + *, + create_transaction: bool = True, + **_kwargs: Any, + ) -> bool: + balance_call.update( + { + "create_transaction": create_transaction, + "description": description, + "amount_kopeks": amount_kopeks, + } + ) + user.balance_kopeks += amount_kopeks + return True + + async def fake_process_referral_topup(*_args: Any, **_kwargs: Any) -> None: + return None + + async def fake_auto_purchase_saved_cart_after_topup(*_args: Any, **_kwargs: Any) -> bool: + return False + + async def fake_has_user_cart(*_args: Any, **_kwargs: Any) -> bool: + return False + + referral_module = ModuleType("app.services.referral_service") + referral_module.process_referral_topup = fake_process_referral_topup # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_module) + + auto_module = ModuleType("app.services.subscription_auto_purchase_service") + auto_module.auto_purchase_saved_cart_after_topup = ( # type: ignore[attr-defined] + fake_auto_purchase_saved_cart_after_topup + ) + monkeypatch.setitem(sys.modules, "app.services.subscription_auto_purchase_service", auto_module) + + user_cart_module = ModuleType("app.services.user_cart_service") + user_cart_module.user_cart_service = SimpleNamespace( # type: ignore[attr-defined] + has_user_cart=fake_has_user_cart + ) + monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_module) + + monkeypatch.setattr( + payment_service_module, + "get_mulenpay_payment_by_uuid", + fake_get_mulenpay_payment_by_uuid, + raising=False, + ) + monkeypatch.setattr( + payment_service_module, + "update_mulenpay_payment_status", + fake_update_mulenpay_payment_status, + raising=False, + ) + monkeypatch.setattr( + payment_service_module, + "create_transaction", + fake_create_transaction, + raising=False, + ) + monkeypatch.setattr( + payment_service_module, + "link_mulenpay_payment_to_transaction", + fake_link_payment, + raising=False, + ) + monkeypatch.setattr( + payment_service_module, + "get_user_by_id", + fake_get_user_by_id, + raising=False, + ) + monkeypatch.setattr( + payment_service_module, + "add_user_balance", + fake_add_user_balance, + raising=False, + ) + + result = await service.process_mulenpay_callback( + db, + {"uuid": payment.uuid, "payment_status": "success", "id": 123, "amount": 1500}, + ) + + assert result is True + assert transaction_calls, "create_transaction should be called" + assert balance_call["create_transaction"] is False + assert dummy_user.balance_kopeks == payment.amount_kopeks + assert payment.transaction_id is not None