"""Клиент Lava.ru Business API (карта / СБП).""" from __future__ import annotations import hashlib import hmac import json import logging from dataclasses import dataclass from typing import Any import httpx logger = logging.getLogger(__name__) API_BASE = "https://api.lava.ru" PAID_STATUSES = frozenset({"success", "paid"}) class LavaError(RuntimeError): pass @dataclass(frozen=True, slots=True) class LavaInvoice: invoice_id: str order_id: str amount: float status: str pay_url: str expired: str | None = None def _json_body(payload: dict[str, Any]) -> bytes: # Порядок ключей и separators критичны для Signature return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") def sign_request(payload: dict[str, Any], secret_key: str) -> str: return hmac.new( secret_key.encode("utf-8"), _json_body(payload), hashlib.sha256, ).hexdigest() def verify_webhook( *, data: dict[str, Any], raw_body: bytes, authorization: str | None, additional_key: str, ) -> bool: """Проверка подписи webhook (два формата Lava).""" key = (additional_key or "").strip() if not key: # без доп. ключа принимаем только если нет подписи вообще (нежелательно) return not data.get("sign") and not (authorization or "").strip() # Новый формат SDK: HMAC SHA256(raw body) в Authorization auth = (authorization or "").strip() if auth: expected = hmac.new(key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() if hmac.compare_digest(auth.lower(), expected.lower()): return True # Классический формат: md5(invoice_id:amount:pay_time:secret_key_2) received = str(data.get("sign") or "").strip() if received: invoice_id = str(data.get("invoice_id") or "") amount = data.get("amount") pay_time = data.get("pay_time") # amount иногда float — приводим как в доке (как пришло / без лишних нулей) amount_s = str(amount) raw = f"{invoice_id}:{amount_s}:{pay_time}:{key}" expected_md5 = hashlib.md5(raw.encode("utf-8")).hexdigest() if hmac.compare_digest(received.lower(), expected_md5.lower()): return True return False class Lava: def __init__( self, shop_id: str, secret_key: str, *, additional_key: str = "", hook_url: str = "", success_url: str = "", fail_url: str = "", expire_minutes: int = 60, ) -> None: self.shop_id = (shop_id or "").strip() self.secret_key = (secret_key or "").strip() self.additional_key = (additional_key or "").strip() self.hook_url = (hook_url or "").strip().rstrip("/") self.success_url = (success_url or "").strip() self.fail_url = (fail_url or "").strip() self.expire_minutes = max(5, min(int(expire_minutes or 60), 5 * 24 * 60)) @property def enabled(self) -> bool: return bool(self.shop_id and self.secret_key) async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: if not self.enabled: raise LavaError("Lava не настроен (LAVA_SHOP_ID / LAVA_SECRET_KEY)") body = _json_body(payload) headers = { "Accept": "application/json", "Content-Type": "application/json", "Signature": sign_request(payload, self.secret_key), } async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post( f"{API_BASE}{path}", headers=headers, content=body, ) try: data = resp.json() except Exception as exc: # noqa: BLE001 raise LavaError(f"HTTP {resp.status_code}: {resp.text[:300]}") from exc if not isinstance(data, dict): raise LavaError(f"Неожиданный ответ: {data}") if resp.status_code >= 400 or not data.get("status_check", True): err = data.get("error") or data.get("message") or data raise LavaError(str(err)) result = data.get("data") if not isinstance(result, dict): raise LavaError(f"Нет data в ответе: {data}") return result async def create_invoice( self, *, amount_rub: float, order_id: str, comment: str = "", custom_fields: str = "", ) -> LavaInvoice: # Порядок ключей фиксируем — от него зависит Signature payload: dict[str, Any] = { "shopId": self.shop_id, "sum": float(f"{amount_rub:.2f}"), "orderId": order_id, } if comment: payload["comment"] = comment[:255] if custom_fields: payload["customFields"] = custom_fields[:500] if self.fail_url: payload["failUrl"] = self.fail_url if self.success_url: payload["successUrl"] = self.success_url if self.hook_url: payload["hookUrl"] = self.hook_url payload["expire"] = self.expire_minutes result = await self._post("/business/invoice/create", payload) return self._parse(result, order_id_fallback=order_id) async def get_invoice( self, *, order_id: str | None = None, invoice_id: str | None = None, ) -> LavaInvoice | None: payload: dict[str, Any] = {"shopId": self.shop_id} if order_id: payload["orderId"] = order_id if invoice_id: payload["invoiceId"] = invoice_id if "orderId" not in payload and "invoiceId" not in payload: raise LavaError("Нужен order_id или invoice_id") try: result = await self._post("/business/invoice/status", payload) except LavaError: return None return self._parse(result, order_id_fallback=order_id or "") @staticmethod def is_paid(status: str) -> bool: return str(status or "").lower() in PAID_STATUSES @staticmethod def _parse(raw: dict[str, Any], *, order_id_fallback: str = "") -> LavaInvoice: return LavaInvoice( invoice_id=str(raw.get("id") or raw.get("invoice_id") or ""), order_id=str(raw.get("orderId") or raw.get("order_id") or order_id_fallback), amount=float(raw.get("amount") or raw.get("sum") or 0), status=str(raw.get("status") or ""), pay_url=str(raw.get("url") or raw.get("payUrl") or ""), expired=str(raw.get("expired") or raw.get("expire") or "") or None, )