Недавно добавил оплату в свой Телеграм‑бот. После некоторых изысканий выбор пал на Тинькофф (ныне Т‑банк). Сам бот работает на 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:

<?php
function tinkoff_getLink($amount, $chatId, $orderNumber) {

    $link = false;

    // Amount нужно передать в копейках, целое число
    // OrderId должен быть уникальным
    $data = [
        "Amount"      => $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 сам.

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

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

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

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

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

  • <title> сайта.

  • Срок регистрации домена.

  • Срок действия SSL-сертификата.

И если что-то из перечисленного на сайте меняется, то бот шлёт уведомление. И ещё он напоминает продлить домен/обновить сертификат за несколько дней до истечения срока.

Я сосредоточился только на этих функциях и постарался сделать их хорошо и понятно. Буду рад, если попробуете и расскажете, получилось ли. Мониторинг одного сайта там останется бесплатным, но буду не менее рад, если и оплату тоже попробуете ;-)

Комментарии (7)


  1. Robastik
    22.09.2024 03:52

    сайт всё равно пригодился для размещения оферты

    Чем оферта на сайте лучше оферты в боте?

    Открывать форму банка тоже лучше в тележке.

    При стеке gas + rtdb не нужен хостинг php, а rtdb не только значительно быстрее, но и часто сильно удобнее. Например, тревога насчёт задвоения просто отсутствует, если писать по уникальному id, т.е. сколько бы раз запись не добавилась, она просто заменит сама себя.


    1. Pontific Автор
      22.09.2024 03:52
      +2

      В оферте надо много чего написать, плюс нужно политику конфиденциальности тоже добавить (вроде). В боте не вижу смысла тратить на это ценное место в описании или в меню.

      Не считаю хостинг php каким-то обременением, он у меня был. Равно как и опыт на php, чего не могу сказать про указанный стэк.


  1. Format-X22
    22.09.2024 03:52
    +2

    У вас потенциальный баг, он может стать неожиданным и потом долго копать чего не так. Кейс - вам банк отправляет запрос с данными мол вот платеж. Вы в базу пишете, отправляете ответ 200 ок. И тут вдруг сеть падает или даже приложение ваше. Ответ не доходит. Банк снова шлёт вам данные - но они уже есть, у вас там уникальный индекс и база кидает ошибку мол нельзя. И всё, поехал бесконечный цикл запросов. Но если таки кейс такой предусмотрен - хорошо.

    Второй возможный кейс - пользователь реально два раза что-то купить хочет. Имеет смысл дать ему это, но менять айди покупки, иначе валидация не пройдёт. Тут правда потенциальный баг тоже можно поймать - клиент купил, но ему ответ не дошел, но решил что просто оплата не прошла, второй раз оплачивает - и ок. А потом обнаруживает что два раза купил. Стоит обрабатывать и такой кейс. Обычно это запрет на покупку того же товара на ту же сумму на стороне самого эквайринга. Но нужно будет быть готовым вернуть назад деньги если таки прошло дважды. Впрочем, это очень тонкий кейс, но за долгое время может встретится. Следует алгоритм продумать на случай такого.


    1. Format-X22
      22.09.2024 03:52
      +1

      К слову, писал про некоторые такие кейсы статью - https://habr.com/ru/companies/exante/articles/826974/

      Уверенность в том что ответ то дойдет если запрос дошел - обыденность.


    1. Pontific Автор
      22.09.2024 03:52
      +3

      Спасибо за предупреждение, первого сценария у меня не возникнет: я банку в любом случае отвечу "OK", даже если данные из его уведомления у меня уже в базе есть.

      Повторные покупки тоже не проблема, потому что к id пользователя добавляется уникальный номер заказа. Номер заказа генерируется в момент создания ссылки.


    1. wmag777
      22.09.2024 03:52

      Нету такого бага.

      Т. К. Ссылка на оплату - это своего рода индивидуальный запрос денег, выставление счета с I'd этого счета


      1. Pontific Автор
        22.09.2024 03:52

        Такой баг возможен, если отправить повторно ранее использованное значение в OrderId.