fix(payments): reuse pending PayKilla invoices
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user