"""Клиент Digiseller: проверка оплаты по уникальному коду.""" from __future__ import annotations import base64 import hashlib import logging import time from dataclasses import dataclass from typing import Any from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import httpx logger = logging.getLogger(__name__) API_BASE = "https://api.digiseller.com" API_BASE_FALLBACK = "https://oplata.info" class DigisellerError(RuntimeError): pass @dataclass(frozen=True, slots=True) class DigisellerPurchase: unique_code: str inv: int product_id: int amount: float currency: str cnt_goods: float email: str | None state: int raw: dict[str, Any] @property def delivered(self) -> bool: # 2/3/4 — уже отмечено как выдано return self.state in {2, 3, 4} class Digiseller: def __init__( self, seller_id: str | int, api_key: str, *, product_ids: tuple[int, ...] = (), pay_url: str = "", credit_from: str = "cnt_goods", ) -> None: self.seller_id = str(seller_id or "").strip() self.api_key = (api_key or "").strip() self.product_ids = product_ids self.pay_url = (pay_url or "").strip() self.credit_from = (credit_from or "cnt_goods").strip().lower() self._token: str | None = None self._token_expires: float = 0.0 @property def enabled(self) -> bool: return bool(self.seller_id and self.api_key) def _sign(self, timestamp: int) -> str: raw = f"{self.api_key}{timestamp}".encode("utf-8") return hashlib.sha256(raw).hexdigest() async def _login(self) -> str: now = time.time() if self._token and now < self._token_expires - 60: return self._token timestamp = int(now) payload = { "seller_id": int(self.seller_id), "timestamp": timestamp, "sign": self._sign(timestamp), } data = await self._request("POST", "/api/apilogin", json_body=payload, auth=False) if int(data.get("retval") or -1) != 0 or not data.get("token"): raise DigisellerError(data.get("desc") or data.get("retdesc") or "login failed") self._token = str(data["token"]) # токен живёт ~2 часа self._token_expires = now + 110 * 60 return self._token async def _request( self, method: str, path: str, *, json_body: dict[str, Any] | None = None, params: dict[str, Any] | None = None, auth: bool = True, ) -> dict[str, Any]: if not self.enabled: raise DigisellerError("Digiseller не настроен") headers = {"Accept": "application/json", "Content-Type": "application/json"} query = dict(params or {}) if auth: query["token"] = await self._login() last_exc: Exception | None = None for base in (API_BASE, API_BASE_FALLBACK): try: async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.request( method, f"{base}{path}", headers=headers, params=query or None, json=json_body, ) if resp.status_code == 401 and auth: self._token = None query["token"] = await self._login() resp = await client.request( method, f"{base}{path}", headers=headers, params=query, json=json_body, ) data = resp.json() if not isinstance(data, dict): raise DigisellerError(f"bad response: {resp.text[:200]}") return data except Exception as exc: # noqa: BLE001 last_exc = exc logger.warning("Digiseller %s %s via %s failed: %s", method, path, base, exc) raise DigisellerError(str(last_exc or "request failed")) async def get_by_unique_code(self, unique_code: str) -> DigisellerPurchase: code = "".join(ch for ch in unique_code.strip() if ch.isalnum()) if len(code) < 10: raise DigisellerError("Некорректный уникальный код") data = await self._request( "GET", f"/api/purchases/unique-code/{code}", ) retval = int(data.get("retval") if data.get("retval") is not None else -1) if retval != 0: raise DigisellerError( str(data.get("retdesc") or data.get("desc") or f"код ошибки {retval}") ) state_obj = data.get("unique_code_state") or {} state = int(state_obj.get("state") or 0) inv = int(data.get("inv") or 0) product_id = int(data.get("id_goods") or 0) if not inv: raise DigisellerError("В ответе Digiseller нет номера счёта (inv)") return DigisellerPurchase( unique_code=code, inv=inv, product_id=product_id, amount=float(data.get("amount") or 0), currency=str(data.get("type_curr") or ""), cnt_goods=float(data.get("cnt_goods") or 0), email=(str(data.get("email")).strip() if data.get("email") else None), state=state, raw=data, ) def assert_payable(self, purchase: DigisellerPurchase) -> None: """Проверка, что код можно принять к зачислению.""" # 1 — ещё не верифицирован (первый запрос), 5 — верифицирован, товар не выдан # 2/3 — уже отмечен выданным (допускаем для дозачисления после сбоя) # 4 — оспорен if purchase.state == 4: raise DigisellerError("Оплата по этому коду оспорена") if purchase.state not in {1, 2, 3, 5}: raise DigisellerError( f"Код ещё не готов к выдаче (state={purchase.state}). " "Оплати товар и попробуй снова." ) date_pay = str((purchase.raw or {}).get("date_pay") or "").strip() if not date_pay and purchase.state == 1: raise DigisellerError("Код ещё не оплачен") async def mark_delivered(self, unique_code: str) -> None: code = "".join(ch for ch in unique_code.strip() if ch.isalnum()) data = await self._request( "PUT", f"/api/purchases/unique-code/{code}/deliver", ) retval = int(data.get("retval") if data.get("retval") is not None else -1) # 4 — уже выдано ранее (идемпотентно ок) if retval not in (0, 4): raise DigisellerError( str(data.get("retdesc") or data.get("desc") or f"deliver error {retval}") ) def credit_amount(self, purchase: DigisellerPurchase) -> float: """Сколько рублей зачислить на баланс бота.""" if self.product_ids and purchase.product_id not in self.product_ids: raise DigisellerError( f"Товар id={purchase.product_id} не разрешён для пополнения" ) if self.credit_from == "amount": value = purchase.amount else: # Типичная схема: товар без фикс. цены, cnt_goods = сумма в ₽ value = purchase.cnt_goods if purchase.cnt_goods > 0 else purchase.amount if value <= 0: raise DigisellerError("Сумма пополнения равна 0") return float(value) def default_pay_url(self) -> str: if self.pay_url: return self.pay_url if self.product_ids: return f"https://oplata.info/asp2/pay.asp?id_d={self.product_ids[0]}" return "https://my.digiseller.com/" def pay_url_for(self, telegram_id: int) -> str: """Ссылка на оплату с привязкой к Telegram ID (уходит в query_string).""" base = self.default_pay_url() parsed = urlparse(base) q = parse_qs(parsed.query, keep_blank_values=True) q["telegram_id"] = [str(int(telegram_id))] # совместимость с разными интеграциями Digiseller q["tg_id"] = [str(int(telegram_id))] new_query = urlencode({k: v[-1] for k, v in q.items()}) return urlunparse(parsed._replace(query=new_query)) @staticmethod def extract_telegram_id(purchase: DigisellerPurchase) -> int | None: """Достать telegram_id из query_string / options платежа.""" raw = purchase.raw or {} qs_b64 = str(raw.get("query_string") or "").strip() if qs_b64: try: pad = "=" * (-len(qs_b64) % 4) decoded = base64.b64decode(qs_b64 + pad).decode("utf-8", "ignore") params = parse_qs(decoded, keep_blank_values=True) for key in ("telegram_id", "tg_id", "tg", "user_id", "uid"): vals = params.get(key) or [] if vals: tid = int(str(vals[0]).strip()) if tid > 0: return tid except Exception: # noqa: BLE001 logger.debug("Digiseller query_string parse failed", exc_info=True) for opt in raw.get("options") or []: if not isinstance(opt, dict): continue name = str(opt.get("name") or "").strip().lower() value = str(opt.get("value") or "").strip() if not value: continue if any( x in name for x in ("telegram", "телеграм", "tg id", "tg_id", "userid", "user id") ): digits = "".join(ch for ch in value if ch.isdigit()) if digits: tid = int(digits) if tid > 0: return tid # иногда покупатель просто вписывает числовой ID if value.isdigit() and len(value) >= 5: return int(value) return None