Integration of Telegram bot with YuKassa
It seems that YuKassa has a good one documentation about setting up payments through a TG bot, there are several on the Internet articles on this topic, but still in practice you come across many non-obvious nuances…
I will describe step by step the process of connecting payments for a Python bot on aiogram 3, provided that its owner has already registered self-employment.
Test mode
So, let's start a dialogue with BotFatherselect your bot and click the Payments button.
We select YuKassa from the list of providers, and in the dialog that appears – Connect YuKassa Test.
We get the settings for testing – standard identifiers and test card data.
We return to BotFather and find that something has changed there:
Let's copy this token to a file .env
our bot. It’s more convenient for me to store two tokens – a test one and a real one for better interchangeability. So far they are the same, because… We don’t have a real one yet.
PROVIDER_TOKEN = "381764678:TEST:100037"
TEST_PROVIDER_TOKEN = "381764678:TEST:100037"
You should immediately add there the currency of future payments in the format ISO-4217 and the amount of payment, must be in kopecks, not rubles.
CURRENCY = "RUB"
PRICE = "9900"
Next, these values need to be loaded. I have a dataclass for this purpose.
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()
Everything is obvious here, except provider_data
. In accordance with Federal Law 54, a receipt must be issued for each payment. I delegated this task to YuKassa, so I had to prepare data for generating checks:
items
– list of goods in the order, for self-employed – no more than 6;description
– product description up to 128 characters long;quantity
– quantity, for the self-employed – necessarily a whole number (and I really wanted to sell only a third of the subscription))));value
– product price in rubles. But we store the price in kopecks, so we divide it by 100 and be sure to indicate the format specifier (.2f
), so as not to lose a substantial amount of 00 kopecks;currency
– currency code;vat_code
– VAT rate, for self-employed we write 1.
The main thing is not to confuse where the price is in rubles and where in kopecks. But why such a discrepancy? It goes back to the Telegram Bot API, where object LabeledPrice
containing the price of the product, stores it in the minimum units of currency – cents, kopecks, etc. If you indicate the value in the check env.int('PRICE')
then the payment simply will not go through.
Now let's create a command handler /buy
which will be used for purchases in our bot.
@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()
Here I'm using aiogram state machine to track if the bot is in payment mode. The state is cleared upon entry and upon emergency exit from the handler.
To ensure that the test card data is always at hand, I send it to the user if a test payment token is specified in the bot settings.
Next we create an array of objects LabeledPrice
for transfer to invoice sending methodi.e. bills to pay. In addition, values previously saved in the bot settings, as well as a required parameter, are passed to this method payload
(a string that the API forces us to use for our internal processes, without even wondering if we need it at all).
It is worth special mentioning the parameters need_phone_number
And send_phone_number_to_provider
. They are needed to send the above-mentioned electronic receipts to customers.
If you have set up fiscalization through YuKassa, then you have two ways to obtain user contacts:
request an email/phone number in advance and pass this value to
provider_data.receipt.email
/provider_data.receipt.phone
;set parameters
need_phone_number
/need_email
Andsend_phone_number_to_provider
/send_email_to_provider
value True. Then YuKassa will request the corresponding value when paying.
My code uses the second method.
The next method we need to implement will be universal. This is the standard code for processing an update like PreCheckoutQuery
to which we need to respond within 10 seconds.
@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}")
Myself PreCheckoutQuery
– This objectcontaining information about the incoming pre-check request and containing the parameters we are familiar with currency
, total_amount
and again mandatory payload
.
Now let's process the successful payment. To catch him, we need a magic filter 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() # чтобы свободно перейти сюда из любого другого состояния
For greater accuracy, data on the amount paid (in kopecks) and the payment currency are taken from the service message about a successful payment. We report all this to the user, save the payment information somewhere in the database (no wonder he paid?) and clear the state.
But if the successful payment filter did not work, and the bot is in the purchasing state, then some kind of error has occurred, about which the user must be notified. That's why I needed a state machine.
@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() # чтобы свободно перейти сюда из любого другого состояния
The filter here is essentially default, so this handler should be the very last one in the entire code fragment related to accepting payments.
The final touch is to check that polling starts without skipping unread updates that have accumulated by the time of launch.
await dp.start_polling(bot, skip_updates=False)
This has been the case for working with payments since the days of aigram 2.
Now you can launch the bot and send it a command /buy
and check that the test payment is successful.
Full mode
To set up real payments, you need to register with YuKassaadd information about your organization and your store. True, we have a bot, not a store, but managers still ask for some kind of interface where they can see a list of products with prices. I sent a screenshot of the help that my bot issued on command /help
.
The result of all these formalities will be your own ShopID
which you will see in your personal account.
Let's now return to our dialogue with BotFather.
Let's select YuKassa and Connect YuKassa Live again. The bot will ask for shopId and shopArticleId (which it advises sending simply 0). Having sent them, we will return to BotFather, where a real payment token of the form will now appear x:LIVE:y
. All that remains is to write it down in PROVIDER_TOKEN
in the file .env
And…
And nothing works!
The fact is that to accept payments in Telegram you need to switch the store to the email protocol. To do this, write an email to ecommerce@yoomoney.ru indicating your ShopID. YuKassa is accustomed to such requests and promptly fulfills them.
Everything is fine now!
Resume
In this article, I tried to put together all the information about setting up integration with YuKassa that I managed to collect on the Internet, through correspondence with technical support and from personal experience. And this experience shows that it is enough to miss the slightest nuance, and YuKassa receives a payment error message without details. I hope that my article will improve this situation a little.