#!/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 {operation}Request>\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()