fix(payments): reuse pending links by provider identity
This commit is contained in:
@@ -295,24 +295,17 @@ class FreeKassaService(HttpClientMixin):
|
|||||||
if not success:
|
if not success:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
expected_currency = normalize_payment_currency_code(getattr(payment, "currency", None))
|
|
||||||
for order in response_data.get("orders") or []:
|
for order in response_data.get("orders") or []:
|
||||||
if not isinstance(order, dict):
|
if not isinstance(order, dict):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
is_new = int(order.get("status", -1)) == 0
|
is_new = int(order.get("status", -1)) == 0
|
||||||
amount_matches = decimal_amounts_equal(
|
|
||||||
order.get("amount"),
|
|
||||||
getattr(payment, "amount", None),
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
if not is_new or not amount_matches:
|
if not is_new:
|
||||||
continue
|
continue
|
||||||
if str(order.get("merchant_order_id") or "") != str(payment.payment_id):
|
if str(order.get("merchant_order_id") or "") != str(payment.payment_id):
|
||||||
continue
|
continue
|
||||||
if normalize_payment_currency_code(order.get("currency")) != expected_currency:
|
|
||||||
continue
|
|
||||||
fk_order_id = str(order.get("fk_order_id") or "").strip()
|
fk_order_id = str(order.get("fk_order_id") or "").strip()
|
||||||
if fk_order_id:
|
if fk_order_id:
|
||||||
payment_url = (self.config.PAYMENT_URL or "https://pay.freekassa.net/").rstrip("/")
|
payment_url = (self.config.PAYMENT_URL or "https://pay.freekassa.net/").rstrip("/")
|
||||||
|
|||||||
@@ -426,17 +426,11 @@ class HeleketService(HttpClientMixin):
|
|||||||
if str(data.get("order_id") or "") != str(payment.payment_id):
|
if str(data.get("order_id") or "") != str(payment.payment_id):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
if not decimal_amounts_equal(data.get("amount"), getattr(payment, "amount", None)):
|
|
||||||
return None
|
|
||||||
expired_at = int(data.get("expired_at") or 0)
|
expired_at = int(data.get("expired_at") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
if expired_at and expired_at <= int(time.time()):
|
if expired_at and expired_at <= int(time.time()):
|
||||||
return None
|
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 (
|
return (
|
||||||
str(data.get("url") or "").strip()
|
str(data.get("url") or "").strip()
|
||||||
or str(getattr(payment, "provider_payment_url", None) or "").strip()
|
or str(getattr(payment, "provider_payment_url", None) or "").strip()
|
||||||
|
|||||||
@@ -330,19 +330,6 @@ class PlategaService(HttpClientMixin):
|
|||||||
if str(data.get("id") or "") != transaction_id:
|
if str(data.get("id") or "") != transaction_id:
|
||||||
return None
|
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:
|
try:
|
||||||
payload = json.loads(str(data.get("payload") or ""))
|
payload = json.loads(str(data.get("payload") or ""))
|
||||||
except (TypeError, ValueError, json.JSONDecodeError):
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ from .shared import (
|
|||||||
PaymentSuccessRequest,
|
PaymentSuccessRequest,
|
||||||
build_payment_record_payload,
|
build_payment_record_payload,
|
||||||
create_webapp_payment_record,
|
create_webapp_payment_record,
|
||||||
decimal_amounts_equal,
|
|
||||||
describe_payment,
|
describe_payment,
|
||||||
finalize_successful_payment,
|
finalize_successful_payment,
|
||||||
finalize_webapp_link_payment,
|
finalize_webapp_link_payment,
|
||||||
@@ -291,15 +290,6 @@ class SeverPayService(HttpClientMixin):
|
|||||||
return None
|
return None
|
||||||
if str(data.get("order_id") or "") != str(payment.payment_id):
|
if str(data.get("order_id") or "") != str(payment.payment_id):
|
||||||
return None
|
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
|
return payment_url
|
||||||
|
|
||||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||||
|
|||||||
@@ -359,6 +359,19 @@ class WataService(HttpClientMixin):
|
|||||||
if not success or not isinstance(data, dict):
|
if not success or not isinstance(data, dict):
|
||||||
return None
|
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()
|
status = _normalized_wata_status(data) or str(data.get("status") or "").strip().lower()
|
||||||
if status and status not in _WATA_LINK_OPENED_STATUSES:
|
if status and status not in _WATA_LINK_OPENED_STATUSES:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ from .shared import (
|
|||||||
append_hwid_renewal_note,
|
append_hwid_renewal_note,
|
||||||
build_success_message,
|
build_success_message,
|
||||||
create_webapp_payment_record,
|
create_webapp_payment_record,
|
||||||
decimal_amounts_equal,
|
|
||||||
format_human_units,
|
format_human_units,
|
||||||
format_number_for_payload,
|
format_number_for_payload,
|
||||||
is_traffic_sale_base,
|
is_traffic_sale_base,
|
||||||
@@ -2962,12 +2961,6 @@ async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optio
|
|||||||
return None
|
return None
|
||||||
if bool(info.get("paid")):
|
if bool(info.get("paid")):
|
||||||
return None
|
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 {}
|
metadata = info.get("metadata") or {}
|
||||||
expected_metadata = {
|
expected_metadata = {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ def _service(session, **config_overrides):
|
|||||||
settings = SimpleNamespace(
|
settings = SimpleNamespace(
|
||||||
DEFAULT_CURRENCY_SYMBOL="RUB",
|
DEFAULT_CURRENCY_SYMBOL="RUB",
|
||||||
DEFAULT_LANGUAGE="ru",
|
DEFAULT_LANGUAGE="ru",
|
||||||
|
PAYMENT_REQUEST_TIMEOUT_SECONDS=15,
|
||||||
traffic_sale_mode=False,
|
traffic_sale_mode=False,
|
||||||
trusted_proxies=[],
|
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"
|
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():
|
def test_try_reuse_pending_link_returns_none_for_closed_link():
|
||||||
service = _service(_FakeSession())
|
service = _service(_FakeSession())
|
||||||
payment = _payment(provider_payment_id="link-id")
|
payment = _payment(provider_payment_id="link-id")
|
||||||
|
|||||||
@@ -57,6 +57,35 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(url, "https://heleket.example/pay/77")
|
self.assertEqual(url, "https://heleket.example/pay/77")
|
||||||
service.get_payment_info.assert_awaited_once_with("invoice-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):
|
async def test_heleket_does_not_reuse_processing_payment(self):
|
||||||
payment = SimpleNamespace(
|
payment = SimpleNamespace(
|
||||||
payment_id=77,
|
payment_id=77,
|
||||||
@@ -111,6 +140,33 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(url, "https://severpay.example/pay/77")
|
self.assertEqual(url, "https://severpay.example/pay/77")
|
||||||
service.get_payment.assert_awaited_once_with("12345")
|
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):
|
async def test_severpay_does_not_reuse_failed_payment(self):
|
||||||
payment = SimpleNamespace(
|
payment = SimpleNamespace(
|
||||||
payment_id=77,
|
payment_id=77,
|
||||||
@@ -173,6 +229,43 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(url, "https://platega.example/pay/77")
|
self.assertEqual(url, "https://platega.example/pay/77")
|
||||||
service.get_transaction.assert_awaited_once_with("transaction-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):
|
async def test_platega_does_not_reuse_other_variant(self):
|
||||||
payment = SimpleNamespace(
|
payment = SimpleNamespace(
|
||||||
payment_id=77,
|
payment_id=77,
|
||||||
@@ -256,7 +349,10 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
payment = SimpleNamespace(payment_id=77, status="pending_platega")
|
payment = SimpleNamespace(payment_id=77, status="pending_platega")
|
||||||
session = AsyncMock()
|
session = AsyncMock()
|
||||||
callback = SimpleNamespace(message=SimpleNamespace(edit_text=AsyncMock()), answer=AsyncMock())
|
callback = SimpleNamespace(
|
||||||
|
message=SimpleNamespace(edit_text=AsyncMock()),
|
||||||
|
answer=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"bot.payment_providers.shared.callbacks.safe_store_provider_payment_id",
|
"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")
|
self.assertEqual(url, "https://freekassa.example/form/12345/order-hash-77")
|
||||||
service.get_orders.assert_awaited_once_with(payment_id=77, order_status=0)
|
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 = SimpleNamespace(
|
||||||
payment_id=77,
|
payment_id=77,
|
||||||
amount=299.0,
|
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):
|
async def test_reusable_payment_response_returns_existing_payment(self):
|
||||||
payment = SimpleNamespace(payment_id=77)
|
payment = SimpleNamespace(payment_id=77)
|
||||||
@@ -434,6 +532,49 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(url, "https://yookassa.example/pay/77")
|
self.assertEqual(url, "https://yookassa.example/pay/77")
|
||||||
service.get_payment_info.assert_awaited_once_with("yk_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):
|
async def test_yookassa_does_not_reuse_invoice_with_other_sale_mode(self):
|
||||||
payment = SimpleNamespace(
|
payment = SimpleNamespace(
|
||||||
payment_id=77,
|
payment_id=77,
|
||||||
|
|||||||
Reference in New Issue
Block a user