#!/usr/bin/env python3 """Однофайловый клиент песочницы i-Free Money — Callisto Merchant XML API v2.6. Single-file sandbox client for the Callisto Merchant XML API v2.6 (one-off payments). Скопируйте файл в свой проект целиком. Только стандартная библиотека, Python 3.10+. Внутри всё, на чём обычно ломается интеграция: * подпись HMAC-SHA256 v3 + тест-векторы (прогоняются ДО первого сетевого вызова); * CreateInvoice -> AuthorizeInvoice (PIN 1234) -> GetInvoice; * проверка подписи входящего коллбека SendPaymentTransaction. Запуск: python callisto_client.py # прогнать тест-векторы подписи (без сети) python callisto_client.py demo # живой сценарий против песочницы Только sandbox-креды: демо-доступы ниже публичны, боевые секреты в этот файл класть нельзя. Документация: https://pay-sandbox.digitalspb.com/help Тест-векторы с разбором: https://pay-sandbox.digitalspb.com/sigtest.json """ from __future__ import annotations import hashlib import hmac import sys import time import urllib.request import xml.etree.ElementTree as ET from datetime import datetime, timezone from xml.sax.saxutils import escape BASE_URL = "https://pay-sandbox.digitalspb.com" ENDPOINT = BASE_URL + "/API/XML/InvoiceManagementService.ashx" # Публичные демо-доступы песочницы — общие для всех, регистрация не нужна LOGIN = "demo_login" SECRET = "demo_password" PROJECT_ID = "1001" # ── Подпись ────────────────────────────────────────────────────────────────── def sign_v3(secret: str, login: str, timestamp: str) -> str: """HMAC-SHA256 v3: два прохода через промежуточный inner. inner = hex(HMAC_SHA256(secret, timestamp)) signature = hex(HMAC_SHA256(secret, login + inner)) inner подставляется во второй проход как hex-строка из 64 символов, НЕ как сырые байты. Тело запроса в подпись не входит. """ key = secret.encode("utf-8") inner = hmac.new(key, timestamp.encode("utf-8"), hashlib.sha256).hexdigest() return hmac.new(key, (login + inner).encode("utf-8"), hashlib.sha256).hexdigest() def now_utc() -> str: """NowUtc XML-контура: yyyyMMddHHmmss UTC. Дрейф больше ±20 минут — ResultCode=101.""" return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") def verify_callback(body_xml: str | bytes, secret: str = SECRET) -> bool: """Проверяет подпись входящего XML-коллбека (SendPaymentTransaction). ВАЖНО: подпись покрывает только Login + NowUtc, тело коллбека НЕ подписано. Не считайте её контрольной суммой содержимого — перед выдачей товара подтвердите состояние инвойса отдельным запросом get_invoice(). """ auth = ET.fromstring(body_xml).find("./Header/Authentication") if auth is None: return False expected = sign_v3(secret, auth.findtext("Login") or "", auth.findtext("NowUtc") or "") return hmac.compare_digest(expected, (auth.findtext("Signature") or "").lower()) # ── Вызовы API ─────────────────────────────────────────────────────────────── def call(operation: str, inner_xml: str) -> ET.Element: """POST одного XML-запроса; operation — имя без суффикса Request.""" now = now_utc() sig = sign_v3(SECRET, LOGIN, now) # подпись пересчитывается на каждый запрос body = ( '\n' "\n" "
\n" f" {escape(LOGIN)}\n" f" {now}\n" f" {sig}\n" "
\n" f" <{operation}Request>\n{inner_xml}\n \n" "
" ) req = urllib.request.Request( ENDPOINT, data=body.encode("utf-8"), headers={"Content-Type": "text/xml; charset=utf-8"}, ) with urllib.request.urlopen(req, timeout=30) as resp: return ET.fromstring(resp.read()) def check_billing(msisdn: str) -> ET.Element: """CheckCustomerBillingSupport — можно ли биллинговать абонента.""" return call( "CheckCustomerBillingSupport", f" {PROJECT_ID}\n" f" {escape(msisdn)}", ) def create_invoice( msisdn: str, amount: str, merchant_order_id: str, expected_result: str = "Success", callback_delay: int = 0, ) -> ET.Element: """CreateInvoice. amount — строка с двумя знаками ("99.00"): float теряет копейки. Блок — песочные ручки, в боевой среде его нет. """ return call( "CreateInvoice", f" {PROJECT_ID}\n" f" {escape(msisdn)}\n" " \n" f" {escape(merchant_order_id)}\n" f' \n' " \n" " \n" f" {escape(expected_result)}\n" f" {int(callback_delay)}\n" " ", ) def authorize_invoice(invoice_id: str, pin: str = "1234") -> ET.Element: """AuthorizeInvoice — подтверждение PIN. В песочнице PIN фиксированный: 1234.""" return call( "AuthorizeInvoice", f" {PROJECT_ID}\n" f" {escape(invoice_id)}\n" f" {escape(pin)}", ) def get_invoice(invoice_id: str) -> ET.Element: """GetInvoice. InvoiceID — строго внутри InvoiceInfo (каноническая форма спеки): плоский под корнем операции песочница не свяжет с инвойсом.""" return call( "GetInvoice", f" {PROJECT_ID}\n" f" {escape(invoice_id)}", ) def result_code(resp: ET.Element) -> str: """ResultCode ответа: "0" — успех, иначе — код ошибки (справочник — в документации).""" return resp.findtext("ResultCode") or "" # ── Тест-векторы (дословно из {BASE_URL}/sigtest.json) ─────────────────────── VECTORS = [ # (login, secret, timestamp, ожидаемая подпись) ( "demo_login", "demo_password", "2026-02-10T17:56:37Z", # формат метки JSON-контура — формула та же "0c902262f3cfc2282b05a2ccf38f59cce766617badf15c458eb32547f0a0841e", ), ( "demo_login", "demo_password", "20260210175637", # формат метки XML-контура (NowUtc) "2f412b1a3b08a2ae3a5a427753bff4fd2d719e911d1eaa057d2cfa9e598dc163", ), ] NEGATIVE_VECTORS = [ # Один проход HMAC по login+timestamp — самая частая неверная реализация ( "demo_login", "demo_password", "2026-02-10T17:56:37Z", "c5261f39339d743804df72d2141653e8f0dc718cd5033f4e04899fed45be067b", ), # inner передан во второй проход сырыми байтами вместо hex-строки ( "demo_login", "demo_password", "2026-02-10T17:56:37Z", "a615c666319b0c5d42a300354accd7f2bae4de418eb5853d438cf370db1e00c7", ), ] def selftest() -> None: """Прогон тест-векторов подписи. Вызывайте ДО первого сетевого запроса: несовпадение двух строк локально отлаживается, 401 от сервера — нет.""" for login, secret, ts, expected in VECTORS: got = sign_v3(secret, login, ts) if got != expected: raise SystemExit( f"вектор не сошёлся (timestamp={ts}):\n" f" ожидали {expected}\n получили {got}\n" f"разбор частых причин: {BASE_URL}/sigtest.json" ) for login, secret, ts, wrong in NEGATIVE_VECTORS: if sign_v3(secret, login, ts) == wrong: raise SystemExit("подпись совпала с заведомо НЕВЕРНОЙ реализацией — сверьте формулу") print(f"signature self-test: OK ({len(VECTORS)} векторов, {len(NEGATIVE_VECTORS)} негативных)") # ── Живой сценарий ─────────────────────────────────────────────────────────── def demo() -> None: """CreateInvoice -> AuthorizeInvoice -> GetInvoice до Processed/PAID.""" # Песочница дедуплицирует инвойсы по клиенту — каждому прогону свой абонент msisdn = "79" + str(time.time_ns())[-9:] order_id = f"CLIENT-DEMO-{int(time.time())}" resp = create_invoice(msisdn, "99.00", order_id) if result_code(resp) != "0": raise SystemExit("CreateInvoice отклонён:\n" + ET.tostring(resp, encoding="unicode")) invoice_id = resp.findtext(".//InvoiceID") or "" print(f"создан инвойс {invoice_id} (заказ {order_id}, абонент {msisdn})") resp = authorize_invoice(invoice_id) print("подтверждён:", resp.findtext(".//InvoiceStatus")) for _ in range(10): # песочница имитирует тарификацию ~6 секунд resp = get_invoice(invoice_id) status = resp.findtext(".//InvoiceStatus") print(f"GetInvoice: {status} / {resp.findtext('.//PaymentStatus')}") if status == "Processed": return time.sleep(2) raise SystemExit("инвойс не дошёл до Processed — смотрите ответы выше") if __name__ == "__main__": selftest() if len(sys.argv) > 1 and sys.argv[1] == "demo": demo()