Подключение оплаты Тинькофф к Telegram-боту на чистом php

Недавно добавил оплату в свой Телеграм‑бот. После некоторых изысканий выбор пал на Тинькофф (ныне Т‑банк). Сам бот работает на php без вспомогательных библиотек. Возможно, кому‑то пригодится мой опыт и код.

Схема следующая:

  1. Пользователь в боте выбирает, на какую сумму хочет пополнить баланс.

  2. Я формирую хитрый запрос к Тинькофф (код ниже).

  3. Тинькофф отвечает ссылкой на оплату.

  4. Я шлю пользователю эту ссылку.

  5. Пользователь переходит по ссылке на домен Тинькофф, там выбирает удобный способ и оплачивает.

  6. Тинькофф присылает мне не только письмо, но и POST‑запрос.

  7. Я забираю из этого запроса сумму, статус и, что самое важное, id пользователя бота.

  8. Пишу пользователю в Telegram благодарность.

Краткая схема оплаты из бота

Краткая схема оплаты из бота

О выборе способа оплаты

Есть два штатных способа оплаты в Telegram‑боте:

  1. Stars — название намекает, что это ≠ деньги, поэтому пока не заставят не хочу.

  2. Через платёжных провайдеров. Это когда прямо внутри Telegram всплывает окошко.

Я попробовал второй способ, начал настраивать ЮKassa. Схема выглядит рабочей, но есть некоторые нюансы:

  1. Нужно вводить номер карты. Это не так прикольно, как в один клик перейти в приложение банка и там безопасно оплатить. Насколько я понял, именно в Tg так не сделать.

  2. Долго с ними как‑то…

Перешёл к следующему варианту: эквайринг от Тинькофф. Я подумал, что как минимум на сайте смогу спокойно встроить платёжный модуль и там принимать оплату, а счёт у меня уже был. Воспользовался Конструктором сайтов тоже от Тинькофф, добавил там оплату, прошёл модерацию, протестировал. Опять нюансы:

  1. Нужно из Telegram передать каким‑то образом id пользователя. Допустим, можно зашить в url.

  2. Далее на форме оплаты этот id нужно подставить в форму. Допустим, можно написать свой код на JS (конструктор позволяет).

  3. Далее вообще неожиданный сюрприз: не смотря на явную настройку «уведомлять об оплате по http», банк не хочет слать мне такое уведомление. Поддержка пояснила:

    Если оплата идет с нашего сайта, то мы передаем всегда свой NotificationURL для нотификации в init и его изменить или удалить нельзя, так как мы без него не узнаем о том, что на сайте оплата произошла, и не сможем прокинуть заказ в заказы. Хорошего вечера!

    Тут мои полномочия всё. Вручную обрабатывать немногочисленные платежи, конечно, можно, но я хочу сразу же уведомлять пользователя, что его деньги дошли.

К счастью, попался сотрудник поддержки, который понял мой конечный замысел. Он пояснил, что в моей схеме сайт — лишнее звено, что подключаться надо по API, дал ссылку на документацию. Что ж, сайт всё равно пригодился для размещения оферты.

Как формировать ссылку на оплату в Тинькофф

Про создание платёжного терминала для Интернет-эквайринга не буду писать: это делается через интерфейс, есть справка. Сосредоточусь на той части, которая в справке обозначена как «помощь программиста».

Для получения платёжной ссылки нам потребуется всего один запрос Init, он описан тут.

Перед отправкой нужно элегантным образом зашифровать пароль в теле запроса и получить Token.

Итак, по шагам:

  1. Сформировать тело запроса — JSON-объект с обязательными полями:

    1. TerminalKey — берётся тут: Личный кабинет → Интернет-эквайринг → Магазины → [Магазин] → Терминалы → Рабочий терминал → Настроить, справа под словом «Терминал».

    2. Amount — сумма в копейках (целое число).

    3. OrderId — должно быть уникальным. Именно сюда я прячу id пользователя, чтобы потом получить его же в уведомлении о платеже, а через дефис добавляю уникальный номер заказа.

    4. Ещё Description наполовину обязательный. Я всегда добавляю, его видит пользователь.

  2. Собрать массив передаваемых данных в виде пар ключ-значения. В массив нужно добавить только параметры корневого объекта. Вложенные объекты и массивы не участвуют в расчёте токена. 

  3. Добавить в массив Password — берётся в личном кабинете, там же где и Терминал.

  4. Отсортировать массив по алфавиту по ключу.

  5. Конкатенировать только значения пар в одну строку (не добавляя разделители).

  6. Применить к строке хэш-функцию SHA-256 (с поддержкой UTF-8).

  7. Получившийся результат поместить в значение параметра Token в тело запроса (которое создали на 1 шаге).

  8. Удалить из тела запроса Password . Его передавать не надо.

  9. Отправить POST-запрос с JSON-телом.

  10. Получить ответ и достать из него заветную ссылку.

Примерно так у меня это получилось на php:

 $amount*100,
        "Description" => 'Пополнение баланса бота "Мониторинг сайта"',
        "OrderId"     => "$chatId-n$orderNumber",
        "TerminalKey" => TINKOFF_TERMINAL_KEY,
        "Password"    => TINKOFF_TERMINAL_PASSWORD
    ];

    ksort($data);
    
    // Получаем все значения из массива
    $values = array_values($data);

    // Конкатенируем все значения в одну строку
    $concatenatedString = implode('', $values);

    // Хэшируем
    $hashedString = hash('sha256', $concatenatedString);

    $data['Token'] = $hashedString;
    unset($data['Password']);
    
    $postDataJson = json_encode($data);

    
    // Настройки cURL
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, TINKOFF_INIT_URL);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_POST, 1);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $postDataJson);

    // Добавляем заголовки для указания того, что тело запроса содержит JSON
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: application/json',
        'Content-Length: ' . strlen($postDataJson)
    ]);

    // Выполнение запроса и получение ответа
    $output = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($output === false || $httpCode !== 200) {
        error_log('Не удалось выполнить запрос, HTTP код: ' . $httpCode);
        return false;
    }
    $outputArray = json_decode($output, true); // true означает декодирование в массив
    
    if (json_last_error() !== JSON_ERROR_NONE) {
        error_log('Ошибка при декодировании JSON: ' . json_last_error_msg());
        return false;
    }

    if (isset($outputArray['Success']) && $outputArray['Success'] === true 
        && isset($outputArray['PaymentURL'])) {

        return $outputArray['PaymentURL'];
    } else {
        error_log("Ссылка не пришла");
        return false;
    }
}

Получившуюся ссылку отправляем пользователю, он может по ней перейти и оплатить.

Как узнать, что пришла оплата

В настройках терминала надо включить уведомления «По протоколу HTTP» и указать свой url. Либо можно передавать NotificationURL в методе Init, он будет иметь приоритет над настройкой терминала (не проверял).

Документация на формат уведомления здесь. Там же перечислены IP-адреса, с которых эти уведомления будут приходить, и алгоритм проверки Token — аналогичен тому, что и при отправке Init.

На это уведомление важно правильно ответить (200-й код, в теле «OK»), иначе эйквайринг будет с упрямством коллектора слать одинаковые уведомления снова и снова. Для подстраховки от задваивания поступлений в базе данных рекомендую разрешить только уникальные комбинации Status + PaymentId. Либо по полю Token.

Чтобы понять, какому пользователю нужно объявить благодарность за оплату, я достаю его id из поля OrderId. Это значение я ранее составил из id пользователя и уникального номера заказа, теперь оно вернулось в уведомлении об оплате. Ещё есть вариант попросить поддержку включить передачу поля DATA, в котором можно отправлять произвольные данные.

Лирическое отступление.

Лёгкость и скорость срабатывания процесса оплаты — от сканирования QR-кода до получения сообщения в Telegram — завораживает. Особенно когда часть кода из этого процесса написал ChatGPT сам.

Удивительно, что где-то ещё считают нормальным процессом оплаты выписывание и отправку по почте бумажных банковских чеков и их учёт в чековой книжке…

А что за бот вообще?

Бот для мониторинга доступности сайтов. Проверяет:

  • Код и скорость ответа.

  • Куда ведёт переадресация.

  • </code> сайта.</p></li><li><p>Срок регистрации домена.</p></li><li><p>Срок действия SSL-сертификата.</p></li></ul> <p>И если что-то из перечисленного на сайте меняется, то бот шлёт уведомление. И ещё он напоминает продлить домен/обновить сертификат за несколько дней до истечения срока.</p> <p>Я сосредоточился только на этих функциях и постарался сделать их хорошо и понятно. Буду рад, если попробуете и расскажете, получилось ли. Мониторинг одного сайта там останется бесплатным, но буду не менее рад, если и оплату тоже попробуете;-)</p> <p class="copyrights"><span class="source">© <a target="_blank" rel="nofollow" href="https://habr.com/ru/articles/845128/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=845128">Habrahabr.ru</a></span></p> </div> <br> <!--<div align="left"> <script type="text/topadvert"> load_event: page_load feed_id: 12105 pattern_id: 8187 tech_model: </script><script type="text/javascript" charset="utf-8" defer="defer" async="async" src="//loader.topadvert.ru/load.js"></script> </div> <br>--> <div style="padding-left: 20px;"> <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-2514821055276660" crossorigin="anonymous"></script> <!-- PCNews 336x280 --> <ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-2514821055276660" data-ad-slot="1200562049" data-ad-format="auto"></ins> <script> (adsbygoogle = window.adsbygoogle || []).push({}); </script> </div> <!-- comments --> <noindex> <div style="margin: 25px;" id="disqus_thread"></div> <script type="text/javascript"> var disqus_shortname = 'pcnewsru'; var disqus_identifier = '1512693'; var disqus_title = 'Подключение оплаты Тинькофф к Telegram-боту на чистом php'; var disqus_url = 'http://pcnews.ru/blogs/podklucenie_oplaty_tinkoff_k_telegram_botu_na_cistom_php-1512693.html'; (function() { var dsq = document.createElement('script'); dsq.type = 'text/javascript'; dsq.async = true; dsq.src = '//' + disqus_shortname + '.disqus.com/embed.js'; (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq); })(); </script> <!--<noscript>Please enable JavaScript to view the <a rel="nofollow" href="http://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>--> <!--<a href="http://disqus.com" rel="nofollow" class="dsq-brlink">comments powered by <span class="logo-disqus">Disqus</span></a>--> </noindex> </div> <br class="clearer"/> </div> <br class="clearer"/> <div id="footer-2nd"></div> <div id="footer"> <br/><br/> <ul class="horz-menu"> <li class="about"><a href="/info/about.html" title="О проекте">О проекте</a></li> <li class="additional-menu"><a href="/archive.html" title="Архив материалов">Архив</a> </li> <li class="additional-menu"><a href="/info/reklama.html" title="Реклама" class="menu-item"><strong>Реклама</strong></a> <a href="/info/partners.html" title="Партнёры" class="menu-item">Партнёры</a> <a href="/info/legal.html" title="Правовая информация" class="menu-item">Правовая информация</a> <a href="/info/contacts.html" title="Контакты" class="menu-item">Контакты</a> <a href="/feedback.html" title="Обратная связь" class="menu-item">Обратная связь</a></li> <li class="email"><a href="mailto:pcnews@pcnews.ru" title="Пишите нам на pcnews@pcnews.ru"><img src="/media/i/email.gif" alt="e-mail"/></a></li> <li style="visibility: hidden"> <noindex> <!-- Rating@Mail.ru counter --> <script type="text/javascript"> var _tmr = window._tmr || (window._tmr = []); _tmr.push({id: "93125", type: "pageView", start: (new Date()).getTime()}); (function (d, w, id) { if (d.getElementById(id)) return; var ts = d.createElement("script"); ts.type = "text/javascript"; ts.async = true; ts.id = id; ts.src = (d.location.protocol == "https:" ? "https:" : "http:") + "//top-fwz1.mail.ru/js/code.js"; var f = function () { var s = d.getElementsByTagName("script")[0]; s.parentNode.insertBefore(ts, s); }; if (w.opera == "[object Opera]") { d.addEventListener("DOMContentLoaded", f, false); } else { f(); } })(document, window, "topmailru-code"); </script> <noscript> <div style="position:absolute;left:-10000px;"> <img src="//top-fwz1.mail.ru/counter?id=93125;js=na" style="border:0;" height="1" width="1" alt="Рейтинг@Mail.ru"/> </div> </noscript> <!-- //Rating@Mail.ru counter --> </noindex> </li> </ul> </div> <!--[if lte IE 7]> <iframe id="popup-iframe" frameborder="0" scrolling="no"></iframe> <![endif]--> <!--<div id="robot-image"><img class="rbimg" src="i/robot-img.png" alt="" width="182" height="305" /></div>--> <!--[if IE 6]> <script>DD_belatedPNG.fix('#robot-image, .rbimg');</script><![endif]--> </div> <!--[if lte IE 7]> <iframe id="ie-popup-iframe" frameborder="0" scrolling="no"></iframe> <![endif]--> <div id="footer-adlinks"></div> <noindex> <!--LiveInternet counter--><script type="text/javascript"> document.write("<a rel='nofollow' href='//www.liveinternet.ru/click' "+ "target=_blank><img src='//counter.yadro.ru/hit?t45.6;r"+ escape(document.referrer)+((typeof(screen)=="undefined")?"": ";s"+screen.width+"*"+screen.height+"*"+(screen.colorDepth? screen.colorDepth:screen.pixelDepth))+";u"+escape(document.URL)+ ";"+Math.random()+ "' alt='' title='LiveInternet' "+ "border='0' width='1' height='1'><\/a>") </script><!--/LiveInternet--> <!-- Rating@Mail.ru counter --> <script type="text/javascript"> var _tmr = window._tmr || (window._tmr = []); _tmr.push({id: "93125", type: "pageView", start: (new Date()).getTime()}); (function (d, w, id) { if (d.getElementById(id)) return; var ts = d.createElement("script"); ts.type = "text/javascript"; ts.async = true; ts.id = id; ts.src = "https://top-fwz1.mail.ru/js/code.js"; var f = function () {var s = d.getElementsByTagName("script")[0]; s.parentNode.insertBefore(ts, s);}; if (w.opera == "[object Opera]") { d.addEventListener("DOMContentLoaded", f, false); } else { f(); } })(document, window, "topmailru-code"); </script><noscript><div> <img src="https://top-fwz1.mail.ru/counter?id=93125;js=na" style="border:0;position:absolute;left:-9999px;" alt="Top.Mail.Ru" /> </div></noscript> <!-- //Rating@Mail.ru counter --> <!-- Yandex.Metrika counter --> <script type="text/javascript"> (function (d, w, c) { (w[c] = w[c] || []).push(function () { try { w.yaCounter23235610 = new Ya.Metrika({ id: 23235610, clickmap: true, trackLinks: true, accurateTrackBounce: true, webvisor: true, trackHash: true }); } catch (e) { } }); var n = d.getElementsByTagName("script")[0], s = d.createElement("script"), f = function () { n.parentNode.insertBefore(s, n); }; s.type = "text/javascript"; s.async = true; s.src = "https://mc.yandex.ru/metrika/watch.js"; if (w.opera == "[object Opera]") { d.addEventListener("DOMContentLoaded", f, false); } else { f(); } })(document, window, "yandex_metrika_callbacks"); </script> <noscript> <div><img src="https://mc.yandex.ru/watch/23235610" style="position:absolute; left:-9999px;" alt=""/> </div> </noscript> <!-- /Yandex.Metrika counter --> <!-- Default Statcounter code for PCNews.ru http://pcnews.ru--> <script type="text/javascript"> var sc_project=9446204; var sc_invisible=1; var sc_security="14d6509a"; </script> <script type="text/javascript" src="https://www.statcounter.com/counter/counter.js" async></script> <!-- End of Statcounter Code --> <script> (function (i, s, o, g, r, a, m) { i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () { (i[r].q = i[r].q || []).push(arguments) }, i[r].l = 1 * new Date(); a = s.createElement(o), m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m) })(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga'); ga('create', 'UA-46280051-1', 'pcnews.ru'); ga('send', 'pageview'); </script> <script async="async" src="/assets/uptolike.js?pid=49295"></script> </noindex> <!--<div id="AdwolfBanner40x200_842695" ></div>--> <!--AdWolf Asynchronous Code Start --> <script type="text/javascript" src="https://pcnews.ru/js/blockAdblock.js"></script> <script type="text/javascript" src="/assets/jquery.min.js"></script> <script type="text/javascript" src="/assets/a70a9c7f/jquery/jquery.json.js"></script> <script type="text/javascript" src="/assets/a70a9c7f/jquery/jquery.form.js"></script> <script type="text/javascript" src="/assets/a70a9c7f/jquery/jquery.easing.1.2.js"></script> <script type="text/javascript" src="/assets/a70a9c7f/jquery/effects.core.js"></script> <script type="text/javascript" src="/assets/a70a9c7f/js/browser-sniff.js"></script> <script type="text/javascript" src="/assets/a70a9c7f/js/scripts.js"></script> <script type="text/javascript" src="/assets/a70a9c7f/js/pcnews-utils.js"></script> <script type="text/javascript" src="/assets/a70a9c7f/js/pcnews-auth.js"></script> <script type="text/javascript" src="/assets/a70a9c7f/js/pcnews-fiximg.js"></script> <script type="text/javascript" src="/assets/a70a9c7f/js/pcnews-infobox.js"></script> </body> </html>