Вроде бы есть у ЮКассы неплохая документация о настройке платежей через ТГ-бота, есть в интернете и несколько статей на эту тему, но все-таки на практике сталкиваешься со множеством неочевидных нюансов…
Опишу по шагам процесс подключения платежей для Python-бота на aiogram 3, при условии, что у его владельца уже оформлена самозанятость.
Тестовый режим
Итак, заходим в диалог с BotFather, выбираем своего бота и нажимаем кнопку Payments.

Выбираем из списка провайдеров Ю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 приходит сообщение об ошибке платежа без подробностей. Надеюсь, что моя статья немного исправит эту ситуацию.
