Вроде бы есть у ЮКассы неплохая документация о настройке платежей через ТГ-бота, есть в интернете и несколько статей на эту тему, но все-таки на практике сталкиваешься со множеством неочевидных нюансов…

Опишу по шагам процесс подключения платежей для Python-бота на aiogram 3, при условии, что у его владельца уже оформлена самозанятость.

Тестовый режим

Итак, заходим в диалог с BotFather, выбираем своего бота и нажимаем кнопку Payments.

Интерфейс BotFather при просмотре бота
Интерфейс BotFather при просмотре бота

Выбираем из списка провайдеров ЮKassa, а в появившемся диалоге – Connect ЮKassa Test.

Два режима - тестовый и реальный
Два режима - тестовый и реальный

Получаем настройки для тестирования – стандартные идентификаторы и данные тестовой карты.

Тестовые настройки
Тестовые настройки

Возвращаемся в BotFather и обнаруживаем, что там кое-что изменилось:

Тестовый платежный токен
Тестовый платежный токен

Скопируем этот токен в файл .env нашего бота. Мне удобнее хранить два токена – тестовый и реальный для лучшей взаимозаменяемости. Пока что они совпадают, т.к. реального у нас пока нет.

PROVIDER_TOKEN = "381764678:TEST:100037"
TEST_PROVIDER_TOKEN = "381764678:TEST:100037"

Сразу же стоит добавить туда валюту будущих платежей в формате ISO-4217 и размер платежа, обязательно в копейках, а не в рублях.

CURRENCY = "RUB"
PRICE = "9900"

Далее эти значения нужно загрузить. У меня для этой цели есть датакласс.

import json
from environs import Env 
from dataclasses import dataclass

@dataclass
class Config:
    __instance = None

    def __new__(cls):
      if cls.__instance is None:
        env: Env = Env()
        env.read_env()
        cls.__instance = super(Config, cls).__new__(cls)
        …
        cls.__instance.provider_token = env('PROVIDER_TOKEN')
        cls.__instance.currency = env('CURRENCY')
        cls.__instance.price = env.int('PRICE')
        provider_data = {
          "receipt": {
            "items": [
              {
                "description": "Подписка на месяц",
                "quantity": "1.00",
                "amount": {
                  "value": f"{cls.__instance.price / 100:.2f}",
                  "currency": cls.__instance.currency
                },
                "vat_code": 1
              }
            ]
          }
        }
        cls.__instance.provider_data = json.dumps(provider_data)
        return cls.__instance

config = Config()

Здесь все очевидно, кроме provider_data. В соответствии с 54-ФЗ за каждый платеж нужно выдавать чек. Я переложила эту задачу на ЮKassa, поэтому мне пришлось заготовить данные для формирования чеков:

  • items – список товаров в заказе, для самозанятых – не более 6;

  • description – описание товара длиной до 128 символов;

  • quantity – количество, для самозанятых – обязательно целое (а так хотелось продать только треть подписки))));

  • value – цена товара в рублях. Но мы-то храним цену в копейках, поэтому делим ее на 100 и обязательно указываем спецификатор формата (.2f), чтобы не потерять солидную сумму 00 копеек;

  • currency – код валюты;

  • vat_code – ставка НДС, для самозанятых пишем 1.

Главное – не перепутать, где цена в рублях, а где в копейках. Но почему такое расхождение? Оно восходит к Telegram Bot API, где объект LabeledPrice, содержащий цену товара, хранит ее в минимальных единицах валюты – центах, копейках и т.д. Если указать в чеке значение env.int('PRICE'), то платеж просто не пройдет.

Теперь создадим обработчик команды /buy, которая будет использоваться для покупок в нашем боте.

@router.message(Command(commands=['buy']))
async def buy_subscription(message: Message, state: FSMContext):
    try:
            # Проверка состояния и его очистка
            current_state = await state.get_state()
            if current_state is not None:
                await state.clear()  # чтобы свободно перейти сюда из любого другого состояния

            from config import config
            if config.provider_token.split(':')[1] == 'TEST':
                await message.reply("Для оплаты используйте данные тестовой карты: 1111 1111 1111 1026, 12/22, CVC 000.")

            prices = [LabeledPrice(label='Оплата заказа', amount=config.price)]
            await state.set_state(FSMPrompt.buying)
            await bot.send_invoice(
                chat_id=message.chat.id,
                title='Покупка,
                description='Оплата бота',
                payload='bot_paid',
                provider_token=config.provider_token,
                currency=config.currency,
                prices=prices,
                need_phone_number=True,
                send_phone_number_to_provider=True,
                provider_data=config.provider_data
            )
    except Exception as e:
        logging.error(f"Ошибка при выполнении команды /buy: {e}")
        await message.answer("Произошла ошибка при обработке команды!")
        current_state = await state.get_state()
        if current_state is not None:
            await state.clear()

Здесь я использую машину состояний aiogram, чтобы отслеживать, находится ли бот в режиме оплаты. Состояние чистится при входе и при аварийном выходе из обработчика.

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

Далее создаем массив объектов LabeledPrice для передачи в метод отправки инвойсов, т.е. счетов на оплату. Кроме того, в этот метод передаются значения, сохраненные ранее в настройках бота, а также обязательный параметр payload (строка, которую API заставляет нас использовать для наших внутренних процессов, нисколько не интересуясь, нужна ли она нам вообще).

Отдельно стоит остановиться на параметрах need_phone_number и send_phone_number_to_provider. Они нужны для отправки покупателям вышеупомянутых электронных чеков.

Если вы настроили фискализацию через ЮKassa, то у вас два пути для получения контактов пользователя:

  • запросить e-mail/телефон заранее и передать это значение в provider_data.receipt.email / provider_data.receipt.phone;

  • задать параметрам need_phone_number/need_email и send_phone_number_to_provider/ send_email_to_provider значение True. Тогда ЮKassa запросит соответствующее значение при оплате.

В моем коде используется второй способ.

Следующий метод, который нам необходимо реализовать, будет универсальным. Это стандартный код для обработки апдейта типа PreCheckoutQuery, на который нам необходимо ответить в течение 10 секунд.

@router.pre_checkout_query()
async def process_pre_checkout_query(pre_checkout_query: PreCheckoutQuery):
    try:
        await bot.answer_pre_checkout_query(pre_checkout_query.id, ok=True)  # всегда отвечаем утвердительно
    except Exception as e:
        logging.error(f"Ошибка при обработке апдейта типа PreCheckoutQuery: {e}")

Сам PreCheckoutQuery – это объект, содержащий информацию о входящем запросе на предварительную проверку и содержащий знакомые нам параметры currency, total_amount и снова обязательный payload.

Теперь обработаем успешный платеж. Чтобы отловить его, нам понадобится магический фильтр F.successful_payment.

@router.message(F.successful_payment)
async def process_successful_payment(message: Message, state: FSMContext, db: Database):
        await message.reply(f"Платеж на сумму {message.successful_payment.total_amount // 100} "
                            f"{message.successful_payment.currency} прошел успешно!")
        await db.update_payment(message.from_user.id)
        logging.info(f"Получен платеж от {message.from_user.id}")
        current_state = await state.get_state()
        if current_state is not None:
            await state.clear()  # чтобы свободно перейти сюда из любого другого состояния

Для вящей точности данные об уплаченной сумме (в копейках) и о валюте расчетов берутся из сервисного сообщения об успешном платеже. Все это мы докладываем пользователю, сохраняем где-то в базе данных информацию об оплате (не зря же он платил?) и очищаем состояние.

Но если фильтр успешной оплаты не сработал, а бот находится в состоянии покупки, то, значит, произошла какая-то ошибка, о чем надо уведомить пользователя. Вот зачем мне понадобилась машина состояний.

@router.message(StateFilter(FSMPrompt.buying))
async def process_unsuccessful_payment(message: Message, state: FSMContext):
        await message.reply("Не удалось выполнить платеж!")
        current_state = await state.get_state()
        if current_state is not None:
            await state.clear()  # чтобы свободно перейти сюда из любого другого состояния

Фильтр здесь, в сущности, дефолтный, поэтому этот обработчик должен стоять самым последним во всем фрагменте кода, относящемся к приему платежей.

Последний штрих – проверим, что поллинг запускается без пропуска непрочитанных апдейтов, накопившихся ко времени запуска.

await dp.start_polling(bot, skip_updates=False)

Так повелось для работы с платежами еще со времен aiogram 2.

Теперь можно запустить бота, отправить ему команду /buy и проверить, что тестовый платеж успешно проходит.

К концу статьи я спохватилась, что надо использовать размытие в редакторе скриншотов)
К концу статьи я спохватилась, что надо использовать размытие в редакторе скриншотов)

Полноценный режим

Чтобы настроить реальные платежи, нужно зарегистрироваться в ЮKassa, добавить данные о своей организации и своем магазине. Правда, у нас бот, а не магазин, но все равно менеджеры запрашивают какой-нибудь интерфейс, где виден список товаров с ценами. Я отправила скрин справки, которую мой бот выдавал по команде /help.

Результатом всех этих формальностей станет ваш собственный ShopID, который вы увидите в личном кабинете.

Вернемся теперь к нашему диалогу с BotFather.

Здесь мы получали тестовый платежный токен, помните?
Здесь мы получали тестовый платежный токен, помните?

Снова выберем ЮKassa и Connect ЮKassa Live. Бот запросит shopId и shopArticleId (в качестве которого советует отправить просто 0). Отправив их, вернемся к BotFather, где появится теперь уже реальный платежный токен вида x:LIVE:y. Осталось записать его в PROVIDER_TOKEN в файле .env и…

И ничего не работает!

Когда есть такой скрин, мем уже не нужен
Когда есть такой скрин, мем уже не нужен

Дело в том, что для приема платежей в Telegram нужно перевести магазин на email-протокол. Для этого напишите письмо на ecommerce@yoomoney.ru с указанием своего ShopID. ЮKassa привычна к таким запросам и оперативно их выполняет.

Теперь все в порядке!

Такое можно и лайкнуть)
Такое можно и лайкнуть)

Резюме

В этой статье я постаралась собрать воедино всю информацию о настройке интеграции с ЮKassa, которую мне удалось собрать в интернете, при переписке с техподдержкой и по личному опыту. А опыт этот показывает, что достаточно упустить малейший нюанс, как от ЮKassa приходит сообщение об ошибке платежа без подробностей. Надеюсь, что моя статья немного исправит эту ситуацию.

Мой бот, где работает прием платежей по этой системе

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


  1. mrprogre
    03.11.2024 19:29

    Да, мне бы эту статью пораньше, меньше бы долбался в мелочах с копейками. Кому надо посмотреть как я сделал на java, смотрите мою статью про бота.


    1. Ioanna Автор
      03.11.2024 19:29

      Да и мне бы вашу статью пораньше) Тема с копейками - это какой-то ужас)


      1. mrprogre
        03.11.2024 19:29

        А я ещё включил авто отправку чеков в налоговую, а там уже нужны копейки после запятой :) мне в итоге поддержка помогла. Нервов потратил нормально, конечно


  1. solo12zw74
    03.11.2024 19:29

    Тоже пользуюсь этой интеграцией. Не пробовал настраивать другие платёжные системы, но с этой всё прошло у довольно легко. Сильно не хватает рекуррентных платежей. Пришлось к боту прикручивать "напоминателя" о необходимости оплатить следующий месяц подписки.


    1. Ioanna Автор
      03.11.2024 19:29

      Да, не хватает...


  1. nki
    03.11.2024 19:29

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


  1. JohnRambo
    03.11.2024 19:29

    Подтверждаю, всё в точности как было и у меня, включая email режим. Думаю, а может стоит сделать режим оплаты который перебрасывает на внешний ресурс и позволяет использовать всякие быстрые платежи, сберпэи и всё остальное.. пока не решил. Но конкретно текущий встроенный в телегу режим работает отлично.


  1. Evgeny_Baulin
    03.11.2024 19:29

    Спасибо! Ваш пост очень сильно помог при развёртывании моего бота для рабочих моментов


    1. Ioanna Автор
      03.11.2024 19:29

      Здорово, что пригодилось!


  1. pavelmakis
    03.11.2024 19:29

    А платежи работают в итоге? У телеграмма написано, что платежи теперь только в их внутренней валюте, иначе бот будет недоступен на iPhone как минимум, за обход комиссии. Насколько это правда? Бот не пропадет у пользователей если подрубить такой эквайринг, или хотя бы выдавать ссылки на стороннюю оплату?


    1. Ioanna Автор
      03.11.2024 19:29

      Да, работают, по крайней мере на Android.


  1. Igorgmail
    03.11.2024 19:29

    Неудобно как то, нету сберпэй, СПб, клиенты не любят вводить номер карты.