#!/usr/bin/env node /** * Однофайловый клиент песочницы i-Free Money — Callisto Merchant XML API v2.6. * Single-file sandbox client for the Callisto Merchant XML API v2.6 (one-off payments). * * Скопируйте файл в свой проект целиком. Node.js 18+ (глобальный fetch), без зависимостей. * Внутри: подпись HMAC-SHA256 v3 + тест-векторы (прогоняются ДО первого сетевого вызова), * CreateInvoice -> AuthorizeInvoice (PIN 1234) -> GetInvoice, проверка подписи коллбека. * * Запуск: * node callisto_client.js // тест-векторы (без сети) * node callisto_client.js demo // живой сценарий против песочницы * node callisto_client.js sign // напечатать подпись * * Только sandbox-креды: демо-доступы ниже публичны, боевые секреты сюда класть нельзя. * Документация: https://pay-sandbox.digitalspb.com/help * Тест-векторы с разбором: https://pay-sandbox.digitalspb.com/sigtest.json */ "use strict"; const crypto = require("node:crypto"); const BASE_URL = "https://pay-sandbox.digitalspb.com"; const ENDPOINT = BASE_URL + "/API/XML/InvoiceManagementService.ashx"; // Публичные демо-доступы песочницы — общие для всех, регистрация не нужна const LOGIN = "demo_login"; const SECRET = "demo_password"; const PROJECT_ID = "1001"; // ── Подпись ────────────────────────────────────────────────────────────────── const hmacHex = (key, msg) => crypto.createHmac("sha256", key).update(msg, "utf8").digest("hex"); /** * HMAC-SHA256 v3: два прохода через промежуточный inner. * inner = hex(HMAC_SHA256(secret, timestamp)) * signature = hex(HMAC_SHA256(secret, login + inner)) * inner подставляется во второй проход как hex-строка из 64 символов, НЕ как сырые * байты. Тело запроса в подпись не входит. */ function signV3(secret, login, timestamp) { const inner = hmacHex(secret, timestamp); return hmacHex(secret, login + inner); } /** NowUtc XML-контура: yyyyMMddHHmmss UTC. Дрейф больше ±20 минут — ResultCode=101. */ function nowUtc() { return new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14); } /** * Проверка подписи входящего XML-коллбека (SendPaymentTransaction). * ВАЖНО: подпись покрывает только Login + NowUtc, тело коллбека НЕ подписано. * Не считайте её контрольной суммой содержимого — перед выдачей товара * подтвердите состояние инвойса отдельным getInvoice(). */ function verifyCallback(bodyXml, secret = SECRET) { const expected = signV3(secret, tagText(bodyXml, "Login"), tagText(bodyXml, "NowUtc")); const provided = tagText(bodyXml, "Signature").toLowerCase(); return ( provided.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided)) ); } // ── Вызовы API ─────────────────────────────────────────────────────────────── const esc = (s) => String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); // Мини-извлечение значения тега: ответы песочницы — плоский XML без CDATA и // повторяющихся тегов. Для сложного XML возьмите настоящий парсер (fast-xml-parser). function tagText(xml, tag) { const m = String(xml).match(new RegExp("<" + tag + ">([^<]*)")); return m ? m[1] : ""; } /** POST одного XML-запроса; operation — имя без суффикса Request. Возвращает текст ответа. */ async function call(operation, innerXml) { const now = nowUtc(); const sig = signV3(SECRET, LOGIN, now); // подпись пересчитывается на каждый запрос const body = `
${esc(LOGIN)} ${now} ${sig}
<${operation}Request> ${innerXml}
`; const resp = await fetch(ENDPOINT, { method: "POST", headers: { "Content-Type": "text/xml; charset=utf-8" }, body, }); return resp.text(); } /** CheckCustomerBillingSupport — можно ли биллинговать абонента. */ function checkBilling(msisdn) { return call( "CheckCustomerBillingSupport", ` ${PROJECT_ID} ${esc(msisdn)}` ); } /** * CreateInvoice. amount — строка с двумя знаками ("99.00"): число теряет копейки. * Блок — песочные ручки, в боевой среде его нет. */ function createInvoice(msisdn, amount, merchantOrderId, expectedResult = "Success") { return call( "CreateInvoice", ` ${PROJECT_ID} ${esc(msisdn)} ${esc(merchantOrderId)} ${esc(expectedResult)} 0 ` ); } /** AuthorizeInvoice — подтверждение PIN. В песочнице PIN фиксированный: 1234. */ function authorizeInvoice(invoiceId, pin = "1234") { return call( "AuthorizeInvoice", ` ${PROJECT_ID} ${esc(invoiceId)} ${esc(pin)}` ); } /** * GetInvoice. InvoiceID — строго внутри InvoiceInfo (каноническая форма спеки): * плоский под корнем операции песочница не свяжет с инвойсом. */ function getInvoice(invoiceId) { return call( "GetInvoice", ` ${PROJECT_ID} ${esc(invoiceId)}` ); } // ── Тест-векторы (дословно из {BASE_URL}/sigtest.json) ─────────────────────── const VECTORS = [ // [login, secret, timestamp, ожидаемая подпись] [ "demo_login", "demo_password", "2026-02-10T17:56:37Z", // формат метки JSON-контура — формула та же "0c902262f3cfc2282b05a2ccf38f59cce766617badf15c458eb32547f0a0841e", ], [ "demo_login", "demo_password", "20260210175637", // формат метки XML-контура (NowUtc) "2f412b1a3b08a2ae3a5a427753bff4fd2d719e911d1eaa057d2cfa9e598dc163", ], ]; const 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", ], ]; /** Прогон тест-векторов подписи. Вызывайте ДО первого сетевого запроса. */ function selftest() { for (const [login, secret, ts, expected] of VECTORS) { const got = signV3(secret, login, ts); if (got !== expected) { throw new Error( `вектор не сошёлся (timestamp=${ts}):\n ожидали ${expected}\n получили ${got}\n` + `разбор частых причин: ${BASE_URL}/sigtest.json` ); } } for (const [login, secret, ts, wrong] of NEGATIVE_VECTORS) { if (signV3(secret, login, ts) === wrong) { throw new Error("подпись совпала с заведомо НЕВЕРНОЙ реализацией — сверьте формулу"); } } console.log( `signature self-test: OK (${VECTORS.length} векторов, ${NEGATIVE_VECTORS.length} негативных)` ); } // ── Живой сценарий ─────────────────────────────────────────────────────────── const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** CreateInvoice -> AuthorizeInvoice -> GetInvoice до Processed/PAID. */ async function demo() { // Песочница дедуплицирует инвойсы по клиенту — каждому прогону свой абонент const msisdn = "79" + String(Date.now()).slice(-9); const orderId = "CLIENT-DEMO-" + Math.floor(Date.now() / 1000); let resp = await createInvoice(msisdn, "99.00", orderId); if (tagText(resp, "ResultCode") !== "0") throw new Error("CreateInvoice отклонён:\n" + resp); const invoiceId = tagText(resp, "InvoiceID"); console.log(`создан инвойс ${invoiceId} (заказ ${orderId}, абонент ${msisdn})`); resp = await authorizeInvoice(invoiceId); console.log("подтверждён:", tagText(resp, "InvoiceStatus")); for (let i = 0; i < 10; i++) { // песочница имитирует тарификацию ~6 секунд resp = await getInvoice(invoiceId); const status = tagText(resp, "InvoiceStatus"); console.log(`GetInvoice: ${status} / ${tagText(resp, "PaymentStatus")}`); if (status === "Processed") return; await sleep(2000); } throw new Error("инвойс не дошёл до Processed — смотрите ответы выше"); } module.exports = { signV3, nowUtc, verifyCallback, checkBilling, createInvoice, authorizeInvoice, getInvoice, selftest, }; if (require.main === module) { const [cmd, ...args] = process.argv.slice(2); if (cmd === "sign") { // node callisto_client.js sign console.log(signV3(args[1], args[0], args[2])); } else { selftest(); if (cmd === "demo") demo().catch((e) => (console.error(e.message), process.exit(1))); } }