From 3170b966b56015b1bb081fc3b0e48f1b7691ab6e Mon Sep 17 00:00:00 2001 From: BADtochka Date: Wed, 10 Jun 2026 04:09:46 +0300 Subject: [PATCH] fix(payments): reuse pending PayKilla invoices --- backend/bot/payment_providers/paykilla.py | 72 +++++++++++++++++++++++ tests/test_webapp_payment_status.py | 50 ++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/backend/bot/payment_providers/paykilla.py b/backend/bot/payment_providers/paykilla.py index 4ee14db..8932d99 100644 --- a/backend/bot/payment_providers/paykilla.py +++ b/backend/bot/payment_providers/paykilla.py @@ -586,6 +586,12 @@ class PaykillaService(HttpClientMixin): query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key) return f"{self.base_url}/api/v2/invoice?{query}&signature={signature}" + def _signed_invoice_details_url(self, invoice_id: str) -> str: + timestamp_ms = int(time.time() * 1000) + recv_window_ms = int(self.config.RECV_WINDOW_MS) + query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key) + return f"{self.base_url}/api/v2/invoice/{invoice_id}?{query}&signature={signature}" + def _signed_currency_url(self) -> str: timestamp_ms = int(time.time() * 1000) recv_window_ms = int(self.config.RECV_WINDOW_MS) @@ -886,6 +892,64 @@ class PaykillaService(HttpClientMixin): logging.exception("Paykilla create_payment_link: request failed.") return False, {"message": str(exc)} + async def get_invoice_details(self, invoice_id: str) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + return False, {"message": "service_not_configured"} + + invoice_id = str(invoice_id or "").strip() + if not invoice_id: + return False, {"message": "missing_invoice_id"} + + headers = { + "X-API-KEY": self.api_key, + "Content-Type": "application/json", + } + session = await self._get_session() + try: + async with session.get( + self._signed_invoice_details_url(invoice_id), + headers=headers, + ) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("Paykilla get_invoice_details: invalid JSON: %s", response_text) + return False, { + "status": response.status, + "message": "invalid_json", + "raw": response_text, + } + invoice = _response_invoice_data(response_data) + if response.status != 200 or not first_value(invoice, "id"): + logging.warning( + "Paykilla get_invoice_details failed: id=%s status=%s body=%s", + invoice_id, + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + return True, invoice + except Exception as exc: + logging.exception("Paykilla get_invoice_details request failed: id=%s", invoice_id) + return False, {"message": str(exc)} + + async def try_reuse_pending_invoice(self, payment: Any) -> Optional[str]: + invoice_id = str(getattr(payment, "provider_payment_id", None) or "").strip() + if not invoice_id: + return None + + success, data = await self.get_invoice_details(invoice_id) + if not success: + return None + if str(first_value(data, "id") or "") != invoice_id: + return None + if str(data.get("clientOrderId") or "") != str(payment.payment_id): + return None + if str(data.get("status") or "").strip().upper() != "PROCESSING": + return None + return f"{self.widget_url}/{invoice_id}" + def _webhook_url_for_request(self, request: web.Request) -> Optional[str]: configured = self.config.full_webhook_url(getattr(self.settings, "WEBHOOK_BASE_URL", None)) if configured: @@ -1259,6 +1323,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: ) +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: PaykillaService = ctx.request.app.get("paykilla_service") + if not service or not service.configured: + return None + return await service.try_reuse_pending_invoice(payment) + + async def paykilla_webhook_route(request: web.Request) -> web.Response: service: PaykillaService = request.app["paykilla_service"] return await service.webhook_route(request) @@ -1570,6 +1641,7 @@ SPEC = PaymentProviderSpec( webhook_path=lambda source: "/webhook/paykilla", webhook_route=paykilla_webhook_route, create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, emoji="", config_class=PaykillaConfig, presentation_class=PaykillaPresentation, diff --git a/tests/test_webapp_payment_status.py b/tests/test_webapp_payment_status.py index c3c9bfb..8c371f1 100644 --- a/tests/test_webapp_payment_status.py +++ b/tests/test_webapp_payment_status.py @@ -9,6 +9,7 @@ from bot.app.web.webapp import billing as billing_module from bot.payment_providers.base import PaymentProviderSpec, WebAppPaymentContext from bot.payment_providers.freekassa import FreeKassaService from bot.payment_providers.heleket import HeleketService +from bot.payment_providers.paykilla import PaykillaService from bot.payment_providers.platega import PlategaService from bot.payment_providers.severpay import SeverPayService from bot.payment_providers.shared import reusable_webapp_payment_response @@ -615,6 +616,55 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): self.assertIsNone(await reuse_webapp_payment(ctx, payment)) + async def test_paykilla_reuses_processing_invoice_by_client_order_id(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="pk_77", + ) + service = object.__new__(PaykillaService) + service.config = SimpleNamespace(WIDGET_URL="https://gopay.paykilla.com") + service.get_invoice_details = AsyncMock( + return_value=( + True, + { + "id": "pk_77", + "clientOrderId": "77", + "status": "PROCESSING", + "totalPrice": "314.00", + "currency": "USD", + }, + ) + ) + + url = await service.try_reuse_pending_invoice(payment) + + self.assertEqual(url, "https://gopay.paykilla.com/pk_77") + service.get_invoice_details.assert_awaited_once_with("pk_77") + + async def test_paykilla_does_not_reuse_invoice_with_other_client_order_id(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="pk_77", + ) + service = object.__new__(PaykillaService) + service.config = SimpleNamespace(WIDGET_URL="https://gopay.paykilla.com") + service.get_invoice_details = AsyncMock( + return_value=( + True, + { + "id": "pk_77", + "clientOrderId": "88", + "status": "PROCESSING", + }, + ) + ) + + self.assertIsNone(await service.try_reuse_pending_invoice(payment)) + async def test_yookassa_pending_payment_refresh_processes_succeeded_provider_status(self): payment = SimpleNamespace( payment_id=42,