From 1fca62de752822f8aa6838b2284ca31099290dc2 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Wed, 10 Jun 2026 03:42:49 +0300 Subject: [PATCH] fix(payments): reuse pending links by provider identity --- backend/bot/payment_providers/freekassa.py | 9 +- backend/bot/payment_providers/heleket.py | 6 - backend/bot/payment_providers/platega.py | 13 -- backend/bot/payment_providers/severpay.py | 10 -- backend/bot/payment_providers/wata.py | 13 ++ backend/bot/payment_providers/yookassa.py | 7 - tests/test_wata_webhook.py | 40 ++++++ tests/test_webapp_payment_status.py | 147 ++++++++++++++++++++- 8 files changed, 198 insertions(+), 47 deletions(-) diff --git a/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py index b3ac8c9..52b0a86 100644 --- a/backend/bot/payment_providers/freekassa.py +++ b/backend/bot/payment_providers/freekassa.py @@ -295,24 +295,17 @@ class FreeKassaService(HttpClientMixin): if not success: return None - expected_currency = normalize_payment_currency_code(getattr(payment, "currency", None)) for order in response_data.get("orders") or []: if not isinstance(order, dict): continue try: is_new = int(order.get("status", -1)) == 0 - amount_matches = decimal_amounts_equal( - order.get("amount"), - getattr(payment, "amount", None), - ) except (TypeError, ValueError): continue - if not is_new or not amount_matches: + if not is_new: continue if str(order.get("merchant_order_id") or "") != str(payment.payment_id): continue - if normalize_payment_currency_code(order.get("currency")) != expected_currency: - continue fk_order_id = str(order.get("fk_order_id") or "").strip() if fk_order_id: payment_url = (self.config.PAYMENT_URL or "https://pay.freekassa.net/").rstrip("/") diff --git a/backend/bot/payment_providers/heleket.py b/backend/bot/payment_providers/heleket.py index 28b4bdb..b711985 100644 --- a/backend/bot/payment_providers/heleket.py +++ b/backend/bot/payment_providers/heleket.py @@ -426,17 +426,11 @@ class HeleketService(HttpClientMixin): if str(data.get("order_id") or "") != str(payment.payment_id): return None try: - if not decimal_amounts_equal(data.get("amount"), getattr(payment, "amount", None)): - return None expired_at = int(data.get("expired_at") or 0) except (TypeError, ValueError): return None if expired_at and expired_at <= int(time.time()): return None - provider_currency = normalize_payment_currency_code(data.get("currency")) - payment_currency = normalize_payment_currency_code(getattr(payment, "currency", None)) - if provider_currency != payment_currency: - return None return ( str(data.get("url") or "").strip() or str(getattr(payment, "provider_payment_url", None) or "").strip() diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index 4a34c21..cbbae10 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -330,19 +330,6 @@ class PlategaService(HttpClientMixin): if str(data.get("id") or "") != transaction_id: return None - details = data.get("paymentDetails") or {} - if not isinstance(details, dict): - return None - try: - if not decimal_amounts_equal(details.get("amount"), getattr(payment, "amount", None)): - return None - except (TypeError, ValueError): - return None - provider_currency = normalize_payment_currency_code(details.get("currency")) - payment_currency = normalize_payment_currency_code(getattr(payment, "currency", None)) - if provider_currency != payment_currency: - return None - try: payload = json.loads(str(data.get("payload") or "")) except (TypeError, ValueError, json.JSONDecodeError): diff --git a/backend/bot/payment_providers/severpay.py b/backend/bot/payment_providers/severpay.py index 3046556..066b64f 100644 --- a/backend/bot/payment_providers/severpay.py +++ b/backend/bot/payment_providers/severpay.py @@ -38,7 +38,6 @@ from .shared import ( PaymentSuccessRequest, build_payment_record_payload, create_webapp_payment_record, - decimal_amounts_equal, describe_payment, finalize_successful_payment, finalize_webapp_link_payment, @@ -291,15 +290,6 @@ class SeverPayService(HttpClientMixin): return None if str(data.get("order_id") or "") != str(payment.payment_id): return None - try: - if not decimal_amounts_equal(data.get("amount"), getattr(payment, "amount", None)): - return None - except (TypeError, ValueError): - return None - provider_currency = normalize_payment_currency_code(data.get("currency")) - payment_currency = normalize_payment_currency_code(getattr(payment, "currency", None)) - if provider_currency != payment_currency: - return None return payment_url async def webhook_route(self, request: web.Request) -> web.Response: diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 6d8fe8c..edae21c 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -359,6 +359,19 @@ class WataService(HttpClientMixin): if not success or not isinstance(data, dict): return None + returned_ids = { + str(data.get("id") or "").strip(), + str(data.get("paymentLinkId") or "").strip(), + str(data.get("payment_link_id") or "").strip(), + } + returned_ids.discard("") + if returned_ids and provider_payment_id not in returned_ids: + return None + + order_id = first_value(data, "orderId", "order_id") + if order_id is not None and str(order_id) != str(payment.payment_id): + return None + status = _normalized_wata_status(data) or str(data.get("status") or "").strip().lower() if status and status not in _WATA_LINK_OPENED_STATUSES: return None diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index 2c5520d..c40ecc1 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -63,7 +63,6 @@ from .shared import ( append_hwid_renewal_note, build_success_message, create_webapp_payment_record, - decimal_amounts_equal, format_human_units, format_number_for_payload, is_traffic_sale_base, @@ -2962,12 +2961,6 @@ async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optio return None if bool(info.get("paid")): return None - if not decimal_amounts_equal(info.get("amount_value"), getattr(payment, "amount", None)): - return None - provider_currency = normalize_payment_currency_code(info.get("amount_currency")) - payment_currency = normalize_payment_currency_code(getattr(payment, "currency", None)) - if provider_currency != payment_currency: - return None metadata = info.get("metadata") or {} expected_metadata = { diff --git a/tests/test_wata_webhook.py b/tests/test_wata_webhook.py index b1f885f..4a69c59 100644 --- a/tests/test_wata_webhook.py +++ b/tests/test_wata_webhook.py @@ -60,6 +60,7 @@ def _service(session, **config_overrides): settings = SimpleNamespace( DEFAULT_CURRENCY_SYMBOL="RUB", DEFAULT_LANGUAGE="ru", + PAYMENT_REQUEST_TIMEOUT_SECONDS=15, traffic_sale_mode=False, trusted_proxies=[], ) @@ -354,6 +355,45 @@ def test_try_reuse_pending_link_returns_url_for_opened_link(): assert url == "https://wata.pro/p/link-id" +def test_try_reuse_pending_link_returns_none_for_other_link_id(): + service = _service(_FakeSession()) + payment = _payment(provider_payment_id="link-id") + + async def fake_get_payment_link(payment_link_id): + assert payment_link_id == "link-id" + return True, { + "id": "other-link-id", + "status": "Opened", + "url": "https://wata.pro/p/other-link-id", + "expirationDateTime": "2099-01-01T00:00:00Z", + } + + service.get_payment_link = fake_get_payment_link + + url = asyncio.run(service.try_reuse_pending_link(payment)) + assert url is None + + +def test_try_reuse_pending_link_returns_none_for_other_order_id(): + service = _service(_FakeSession()) + payment = _payment(provider_payment_id="link-id") + + async def fake_get_payment_link(payment_link_id): + assert payment_link_id == "link-id" + return True, { + "id": "link-id", + "orderId": "999", + "status": "Opened", + "url": "https://wata.pro/p/link-id", + "expirationDateTime": "2099-01-01T00:00:00Z", + } + + service.get_payment_link = fake_get_payment_link + + url = asyncio.run(service.try_reuse_pending_link(payment)) + assert url is None + + def test_try_reuse_pending_link_returns_none_for_closed_link(): service = _service(_FakeSession()) payment = _payment(provider_payment_id="link-id") diff --git a/tests/test_webapp_payment_status.py b/tests/test_webapp_payment_status.py index 524889f..c3c9bfb 100644 --- a/tests/test_webapp_payment_status.py +++ b/tests/test_webapp_payment_status.py @@ -57,6 +57,35 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): self.assertEqual(url, "https://heleket.example/pay/77") service.get_payment_info.assert_awaited_once_with("invoice-77") + async def test_heleket_reuses_by_order_id_when_provider_amount_includes_fee(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="invoice-77", + provider_payment_url=None, + ) + service = object.__new__(HeleketService) + service.get_payment_info = AsyncMock( + return_value=( + True, + { + "uuid": "invoice-77", + "order_id": "77", + "amount": "314.00", + "currency": "RUB", + "payment_status": "check", + "is_final": False, + "expired_at": int(time.time()) + 900, + "url": "https://heleket.example/pay/77", + }, + ) + ) + + url = await service.try_reuse_pending_payment(payment) + + self.assertEqual(url, "https://heleket.example/pay/77") + async def test_heleket_does_not_reuse_processing_payment(self): payment = SimpleNamespace( payment_id=77, @@ -111,6 +140,33 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): self.assertEqual(url, "https://severpay.example/pay/77") service.get_payment.assert_awaited_once_with("12345") + async def test_severpay_reuses_by_order_id_when_provider_amount_includes_fee(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="12345", + provider_payment_url="https://severpay.example/pay/77", + ) + service = object.__new__(SeverPayService) + service.get_payment = AsyncMock( + return_value=( + True, + { + "id": 12345, + "uid": "payment-uid-77", + "order_id": "77", + "amount": 314.0, + "currency": "RUB", + "status": "new", + }, + ) + ) + + url = await service.try_reuse_pending_payment(payment) + + self.assertEqual(url, "https://severpay.example/pay/77") + async def test_severpay_does_not_reuse_failed_payment(self): payment = SimpleNamespace( payment_id=77, @@ -173,6 +229,43 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): self.assertEqual(url, "https://platega.example/pay/77") service.get_transaction.assert_awaited_once_with("transaction-77") + async def test_platega_reuses_by_payload_when_provider_amount_includes_fee(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="transaction-77", + provider_payment_url="https://platega.example/pay/77", + ) + service = object.__new__(PlategaService) + service.get_transaction = AsyncMock( + return_value=( + True, + { + "id": "transaction-77", + "status": "PENDING", + "paymentDetails": {"amount": 314.0, "currency": "RUB"}, + "payload": json.dumps( + { + "payment_db_id": 77, + "user_id": 1001, + "sale_mode": "subscription@standard", + "platega_variant": "sbp", + } + ), + }, + ) + ) + + url = await service.try_reuse_pending_transaction( + payment, + user_id=1001, + sale_mode="subscription@standard", + variant="sbp", + ) + + self.assertEqual(url, "https://platega.example/pay/77") + async def test_platega_does_not_reuse_other_variant(self): payment = SimpleNamespace( payment_id=77, @@ -256,7 +349,10 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): payment = SimpleNamespace(payment_id=77, status="pending_platega") session = AsyncMock() - callback = SimpleNamespace(message=SimpleNamespace(edit_text=AsyncMock()), answer=AsyncMock()) + callback = SimpleNamespace( + message=SimpleNamespace(edit_text=AsyncMock()), + answer=AsyncMock(), + ) with patch( "bot.payment_providers.shared.callbacks.safe_store_provider_payment_id", @@ -321,7 +417,7 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): self.assertEqual(url, "https://freekassa.example/form/12345/order-hash-77") service.get_orders.assert_awaited_once_with(payment_id=77, order_status=0) - async def test_freekassa_does_not_reuse_order_with_other_amount(self): + async def test_freekassa_reuses_by_order_id_when_provider_amount_includes_fee(self): payment = SimpleNamespace( payment_id=77, amount=299.0, @@ -347,7 +443,9 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): ) ) - self.assertIsNone(await service.try_reuse_pending_order(payment)) + url = await service.try_reuse_pending_order(payment) + + self.assertEqual(url, "https://freekassa.example/form/12345/order-hash-77") async def test_reusable_payment_response_returns_existing_payment(self): payment = SimpleNamespace(payment_id=77) @@ -434,6 +532,49 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): self.assertEqual(url, "https://yookassa.example/pay/77") service.get_payment_info.assert_awaited_once_with("yk_77") + async def test_yookassa_reuses_by_metadata_when_provider_amount_includes_fee(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + yookassa_payment_id="yk_77", + provider_payment_id=None, + ) + service = SimpleNamespace( + configured=True, + get_payment_info=AsyncMock( + return_value={ + "id": "yk_77", + "status": "pending", + "paid": False, + "amount_value": 314.0, + "amount_currency": "RUB", + "metadata": { + "user_id": "1001", + "payment_db_id": "77", + "sale_mode": "subscription@standard", + }, + "confirmation_url": "https://yookassa.example/pay/77", + } + ), + ) + ctx = WebAppPaymentContext( + request=SimpleNamespace(app={"yookassa_service": service}), + session=AsyncMock(), + user_id=1001, + method="yookassa", + months=3, + price=299.0, + stars_price=None, + description="Subscription", + sale_mode="subscription@standard", + currency="RUB", + ) + + url = await reuse_webapp_payment(ctx, payment) + + self.assertEqual(url, "https://yookassa.example/pay/77") + async def test_yookassa_does_not_reuse_invoice_with_other_sale_mode(self): payment = SimpleNamespace( payment_id=77,