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