fix(payments): reuse pending PayKilla invoices

This commit is contained in:
BADtochka
2026-06-10 04:09:46 +03:00
parent 1fca62de75
commit 3170b966b5
2 changed files with 122 additions and 0 deletions
+72
View File
@@ -586,6 +586,12 @@ class PaykillaService(HttpClientMixin):
query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key) query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key)
return f"{self.base_url}/api/v2/invoice?{query}&signature={signature}" 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: def _signed_currency_url(self) -> str:
timestamp_ms = int(time.time() * 1000) timestamp_ms = int(time.time() * 1000)
recv_window_ms = int(self.config.RECV_WINDOW_MS) recv_window_ms = int(self.config.RECV_WINDOW_MS)
@@ -886,6 +892,64 @@ class PaykillaService(HttpClientMixin):
logging.exception("Paykilla create_payment_link: request failed.") logging.exception("Paykilla create_payment_link: request failed.")
return False, {"message": str(exc)} 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]: def _webhook_url_for_request(self, request: web.Request) -> Optional[str]:
configured = self.config.full_webhook_url(getattr(self.settings, "WEBHOOK_BASE_URL", None)) configured = self.config.full_webhook_url(getattr(self.settings, "WEBHOOK_BASE_URL", None))
if configured: 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: async def paykilla_webhook_route(request: web.Request) -> web.Response:
service: PaykillaService = request.app["paykilla_service"] service: PaykillaService = request.app["paykilla_service"]
return await service.webhook_route(request) return await service.webhook_route(request)
@@ -1570,6 +1641,7 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/paykilla", webhook_path=lambda source: "/webhook/paykilla",
webhook_route=paykilla_webhook_route, webhook_route=paykilla_webhook_route,
create_webapp_payment=create_webapp_payment, create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
emoji="", emoji="",
config_class=PaykillaConfig, config_class=PaykillaConfig,
presentation_class=PaykillaPresentation, presentation_class=PaykillaPresentation,
+50
View File
@@ -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.base import PaymentProviderSpec, WebAppPaymentContext
from bot.payment_providers.freekassa import FreeKassaService from bot.payment_providers.freekassa import FreeKassaService
from bot.payment_providers.heleket import HeleketService 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.platega import PlategaService
from bot.payment_providers.severpay import SeverPayService from bot.payment_providers.severpay import SeverPayService
from bot.payment_providers.shared import reusable_webapp_payment_response 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)) 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): async def test_yookassa_pending_payment_refresh_processes_succeeded_provider_status(self):
payment = SimpleNamespace( payment = SimpleNamespace(
payment_id=42, payment_id=42,