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