Экран оплаты закрылся, пользователь вернулся в чат, и у него на лице написано: «Я заплатил, где моё?» Для бота этот момент обманчив. Закрытое окно ещё не доказывает, что деньги списаны и что сообщение о них дошло до вашего сервера. Поэтому первая задача платного бота состоит не в красивой кнопке «Купить», а в том, чтобы точно знать, какое событие считается подтверждением платежа. Всё остальное (срок, продление, отмена, возврат) строится вокруг этого решения.
Ниже разобран один сквозной сценарий: бот продаёт цифровой доступ, например закрытый раздел с материалами или расширенный режим работы. Условный пример такой: подписка на 30 дней, один тариф, один пользователь. Продукт простой, но на нём хорошо видно, где у разработчиков обычно появляются дыры.
Три момента одной оплаты: счёт, проверка и подтверждение
Покупка в боте состоит из нескольких отдельных событий, и путать их опасно. Сначала бот отправляет пользователю счёт. На этом этапе ничего не оплачено: есть только предложение с названием, описанием и суммой. Затем Telegram перед списанием присылает боту запрос предварительной проверки, то есть pre_checkout_query. Это вопрос «ты готов принять этот заказ?», а не сообщение «деньги получены». Наконец, после реального списания приходит сообщение с successful_payment. Только оно говорит, что платёж состоялся.
Для цифровых товаров внутри Telegram действует правило: они оплачиваются Stars. Документация Telegram о платежах за цифровые товары также указывает, что выдачу нужно делать после successful_payment, а не только по pre_checkout, и что идентификатор оплаты нужен для возврата (Telegram: Digital goods payments). Из этого вытекает практическое разделение обязанностей между тремя шагами.
- Счёт создаёт намерение. В своей базе его удобно записать как заказ в состоянии «ожидает оплаты» с вашим внутренним номером, пользователем и тарифом.
- Предварительная проверка нужна, чтобы быстро убедиться: заказ существует, тариф ещё продаётся, пользователь не заблокирован. Здесь вы отвечаете «да» или «нет», но права не выдаёте.
- Успешный платёж фиксирует факт оплаты и запускает выдачу доступа.
Почему нельзя выдать доступ на предварительной проверке? Потому что между «готов принять» и «списано» может произойти отказ, отмена или сбой. Если вы уже открыли раздел, а оплата не прошла, получится бесплатная подписка, причём с аккуратной записью в логах о вашей собственной ошибке.
Есть и обратная сторона. Предварительная проверка имеет ограниченное время на ответ, поэтому тяжёлую работу в ней делать не стоит: никаких долгих запросов к внешним сервисам и пересчётов. Проверили локальные данные, ответили. Всё остальное подождёт до подтверждённого платежа.
Что записать в момент подтверждения
Когда приходит successful_payment, у вас в руках оказывается самый ценный предмет всего процесса: идентификатор оплаты. По документации он понадобится для возврата, а значит, потерять его нельзя. Не стоит держать его только в памяти процесса, в логе приложения или в тексте сообщения пользователю. Нужна отдельная запись в базе.
Хорошая привычка здесь одна: разделить журнал платежей и состояние подписки. Это две разные таблицы с разной жизнью.
Журнал платежей отвечает на вопрос «что происходило». Он только дописывается. В нём лежат идентификатор оплаты, сумма в Stars, пользователь, ваш номер заказа, время получения, статус платежа (получен, возвращён) и ссылка на тариф. Записи оттуда не стирают и не переписывают, потому что это ваша бухгалтерская память.
Состояние подписки отвечает на вопрос «что можно прямо сейчас». Оно изменяется: срок продлевается, право истекает, доступ отключается. Тут лежат пользователь, тариф, дата начала текущего периода, дата окончания, флаг автопродления в вашем понимании и ссылка на платёж, из-за которого состояние изменилось.
Если смешать эти вещи в одну строку «пользователь — оплачено до даты», история исчезнет при первом же продлении. Через месяц вы не сможете ответить на простой вопрос поддержки: «Какой именно платёж дал мне доступ, и вернули ли мне деньги?»
Минимальная схема для нашего примера выглядит так:
payments
payment_id -- идентификатор оплаты из successful_payment, уникальный
user_id
order_id -- ваш внутренний номер заказа
amount_stars
received_at
status -- received | refunded
subscriptions
user_id
plan
period_start
period_end
state -- см. временную линию ниже
last_payment_id -- ссылка на payments.payment_idКлючевая деталь: уникальность по идентификатору оплаты. Она понадобится в следующем разделе, когда речь пойдёт о повторах.
Временная линия подписки: какие бывают состояния
Теперь определим, что вообще значит «подписка» в вашем боте. Слово обманчиво: в нём прячутся несколько разных ситуаций. Удобно думать не о флажке «активна», а о линии времени, на которой подписка проходит через понятные состояния.
| Состояние | Когда наступает | Что может пользователь |
|---|---|---|
| Нет доступа | Платежа не было или доступ окончательно закрыт | Видит предложение купить |
| Ожидает оплаты | Счёт отправлен, подтверждения ещё нет | Доступа нет, можно завершить оплату |
| Активна | Получен successful_payment, период не закончился | Пользуется всем тарифом |
| Отменена, но действует | Пользователь отказался от продления, срок ещё не вышел | Пользуется до конца оплаченного периода |
| Истекла | Дата окончания прошла, нового платежа нет | Доступ закрыт, история сохранена |
| Возвращена | По платежу оформлен возврат | Доступ закрыт по решению оператора или правилам бота |
Обратите внимание на строку «Отменена, но действует». Это самый частый источник путаницы. Отмена не сжигает оплаченное время. Пользователь заплатил за тридцать дней, на десятый день передумал продлевать, и у него осталось ещё двадцать. Если вы закроете доступ сразу, вы лишите человека того, за что он уже заплатил, и получите законную претензию. Если, наоборот, вы будете считать отмену отсутствием события, то начнёте просить деньги за следующий период, о котором он вас не просил.
Отдельно подчеркнём, что мы здесь описываем состояния вашего бота, а не механику рекуррентного списания на стороне платёжной платформы. Какие именно периоды и режимы автоматических подписок поддерживает Telegram для цифровых товаров, нужно сверять в актуальной документации. В описанной схеме надёжный минимум другой: каждый оплаченный период подтверждается отдельным successful_payment, а ваш бот сам решает, что за этим следует.
Продление, истечение и отмена: три разных события
Эти три слова часто звучат как синонимы «подписка кончилась», но в коде это три самостоятельных события с разными причинами.
Продление
Продление происходит тогда, когда приходит новый подтверждённый платёж, а у пользователя уже есть подписка. Важный выбор: от какой даты отсчитывать новый период. Есть два разумных варианта.
- От текущей даты окончания, если подписка ещё действует. Пользователь заплатил заранее, и оплаченные дни не пропадают. Окончание 10 марта плюс 30 дней даёт 9 апреля.
- От момента платежа, если подписка уже истекла. Тут нет смысла отсчитывать от давно прошедшей даты, иначе человек купит месяц, а получит пару дней.
Правило в одну строку: новый конец периода равен максимуму из «сейчас» и «старый конец», плюс длительность тарифа. Оно закрывает оба случая одной формулой, и вам не нужны ветвления с исключениями.
Истечение
Истечение не вызывается пользователем и не приходит от платёжной системы. Его порождает время. Значит, нужен механизм, который замечает, что period_end наступил. Тут два подхода, и их лучше сочетать.
Первый: проверять срок в момент обращения. Когда пользователь жмёт кнопку защищённой функции, бот сравнивает текущее время с датой окончания и отказывает, если срок прошёл. Это надёжно, потому что решение принимается в тот момент, когда оно нужно.
Второй: периодическая фоновая задача, которая переводит просроченные записи в состояние «истекла» и, например, убирает пользователя из закрытого чата или отправляет напоминание. Это уже удобство и порядок, а не защита. Если задача один раз не сработает, проверка при обращении всё равно не пропустит человека без оплаты. Такую фоновую задачу вполне можно запускать на небольшом VPS рядом с ботом, ей не нужны большие ресурсы.
Раздельная работа двух механизмов даёт спокойствие: ошибка в планировщике превращается в неаккуратный интерфейс, а не в бесплатный доступ.
Отмена
Отмена означает отказ пользователя продлевать подписку. В нашей схеме она ничего не списывает и не возвращает, а лишь меняет намерение: состояние становится «отменена, но действует», а напоминания о продлении прекращаются. Когда придёт дата окончания, подписка перейдёт в «истекла» обычным порядком.
Заметьте, что отмена и возврат тоже разные вещи. Отмена касается будущего: «больше не хочу». Возврат касается прошлого платежа: «верните деньги за то, что уже оплачено». Пользователь может отменить подписку и не просить деньги назад. Он может, напротив, потребовать возврат и тем самым лишиться доступа сразу. К возврату мы вернёмся отдельно.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Когда одно и то же сообщение приходит дважды
Теперь о неприятной стороне сетевой жизни. Бот получает обновления от Telegram либо через webhook, либо через getUpdates, и эти способы взаимоисключающие: пока настроен webhook, получать обновления через getUpdates нельзя. Также входящие обновления хранятся ограниченное время (Telegram: Getting updates). Из этого следуют два практических вывода.
Первый: если ваш сервер долго был недоступен, обновления не будут ждать вас вечно. Бот, лежавший без дела слишком долго, может потерять часть сообщений. Для платежей это значит, что вам нужен способ сверки: хранить собственный журнал и быть готовым найти расхождения, а не надеяться, что каждое событие обязательно дойдёт.
Второй вывод связан с повторной доставкой. Если ваш обработчик не успел ответить вовремя или упал после выполнения половины работы, то одно и то же обновление может прийти снова. Это обычное поведение сетевых систем, и ждать от него исключений не стоит. Поэтому обработка платежа обязана выдерживать повтор.
Вот где пригождается уникальность идентификатора оплаты в журнале. Алгоритм простой:
- Получили
successful_payment, достали идентификатор оплаты. - Попытались записать его в таблицу
payments. Если запись с таким идентификатором уже есть, значит, это повтор. - При повторе не продлевать срок второй раз, а просто ответить успехом и, возможно, ещё раз показать пользователю статус.
- При первой записи в той же транзакции своей базы обновить
subscriptions.
Пункт 4 требует осторожности в формулировках. Транзакция вашей базы надёжно связывает запись платежа и изменение подписки, потому что обе таблицы лежат в одной базе. Но если вы в том же обработчике отправляете сообщение пользователю или вызываете внешний сервис, эти действия в транзакцию не входят. Они могут не выполниться, даже когда запись сохранена. Поэтому порядок такой: сначала надёжно сохраняем факт и состояние, потом отправляем уведомления. А уведомление должно быть безопасным при повторе, ведь лишнее «Спасибо за оплату!» хуже, чем потерянное, но не настолько страшно, как двойное продление.
Выдача права, которое уже оплачено
Особый случай: платёж записан, а выдача права нет. Бывает, что процесс упал между записью и, скажем, добавлением человека в закрытый канал. Пользователь заплатил, платёж есть в журнале, но доступа нет.
Если вы делаете выдачу права внешним шагом, держите в записи подписки понятный признак: право выдано или ещё нет. Тогда фоновая задача может периодически искать подписки в состоянии «активна, но право не выдано» и доводить их до конца. А когда приходит повтор уведомления, обработчик, увидев известный идентификатор, не пропускает дальше слепо: он проверяет, выдано ли право, и при необходимости выдаёт его. Так повтор превращается из угрозы в способ самоисцеления.
Здесь легко ошибиться в другую сторону и решить, что повтор можно просто игнорировать. Если игнорировать его безусловно, пострадает как раз тот, у кого сбой случился на полпути: платёж есть, права нет, и ни одного события больше не придёт. Поэтому правило формулируется так: повторная запись платежа запрещена, а повторная проверка выдачи разрешена и желательна.
Возврат: изменить право, но сохранить историю
Возврат начинается с того же идентификатора оплаты, который вы сохранили при подтверждении. Документация указывает, что он нужен именно для возврата (Telegram: Digital goods payments). Если бы вы его не записали, возвращать пришлось бы вслепую, перебирая историю. Вот почему он и записывается в первую очередь. Конкретные условия возврата и правила тарификации сверяйте в документации отдельно, они не относятся к логике вашей базы данных.
Теперь главный вопрос: что делать с данными при возврате? Соблазн велик: удалить платёж, удалить подписку, будто ничего не было. Это ошибка по нескольким причинам.
- Пропадёт доказательство, что платёж был и что деньги вернули. При споре вам нечем будет подтвердить ни то ни другое.
- Исчезнет связь между пользователем и прошлым доступом. Вы не сможете понять, возвращал ли человек деньги раньше.
- Если процедура возврата прервётся на середине, вы потеряете след, и непонятно будет, прошла операция или нет.
Правильнее разделить два действия. В журнале платежей статус меняется с «получен» на «возвращён», и добавляется время возврата. Сама запись остаётся. В состоянии подписки меняется право: подписка переходит в «возвращена», доступ закрывается. Что именно закрывать, зависит от вашего правила. Если возвращён единственный платёж периода, логично закрыть доступ сразу. Если у человека было два последовательных продления и вернули только последний, срок нужно пересчитать: вычесть дни именно этого платежа и посмотреть, остался ли доступ от предыдущего.
Это хороший повод держать в таблице подписок ссылку на последний платёж, а в журнале хранить длительность, которую платёж принёс. Тогда пересчёт превращается в арифметику, а не в гадание.
Порядок действий при возврате
Здесь тоже важен порядок, и снова из-за того, что внешний вызов и ваша база не образуют единой атомарной операции. Разумная последовательность такая:
- Пометить в журнале намерение: платёж в процессе возврата.
- Выполнить сам возврат по идентификатору оплаты.
- После успешного ответа записать статус «возвращён» и закрыть или пересчитать право.
- Если ответ не получен или была ошибка, оставить пометку «в процессе» и разобрать случай повторной попыткой или вручную, не угадывая исход.
Промежуточное состояние неудобно, зато честно: оно прямо говорит, что исход неизвестен. Это лучше, чем уверенно записанное «возвращён» после неудавшейся операции или, наоборот, «получен» после реально выполненной.
Где всё это живёт: бот, база и сервер
Описанная схема не требует сложной инфраструктуры. Нужен процесс бота, который принимает обновления через webhook или getUpdates, база данных с двумя таблицами и фоновая задача для истечения сроков и доведения выдачи до конца. Для такого набора подойдёт скромный VPS: процесс бота, небольшая база и планировщик спокойно сосуществуют на одной машине. Среди тарифов MAATRIX начальный Ba даёт 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe, а Uab удваивает процессор и вдвое увеличивает память и диск. Что именно хватит вашему боту, покажет только замер под реальной нагрузкой: ресурсы тарифа не равны обещанию ёмкости.
Есть практическое преимущество собственного сервера с root-доступом: вы сами выбираете базу, расписание задач и способ хранения журнала, и ничто не ограничивает вас в организации фоновой сверки. Описание тарифов и локаций приведено на странице аренды VPS MAATRIX. Резервные копии базы платежей стоит настроить самостоятельно: наличие автоматических копий во всех тарифах не подтверждено, так что рассчитывать на них не надо. Журнал платежей слишком ценен, чтобы жить без копии.
Если ваш бот помимо платного доступа выполняет ещё и содержательную работу, например отвечает через языковую модель, посмотрите разбор как поднять AI-ассистента в Telegram на сервере: платная подписка в таком боте как раз определяет, кому эта работа доступна.
Рабочий пример: одна подписка от покупки до возврата
Соберём всё в один условный сценарий с вымышленными числами, чтобы увидеть, как состояния и записи согласуются друг с другом. Пусть тариф стоит условные 100 Stars за 30 дней.
- 1 марта. Пользователь нажимает «Купить». Бот создаёт заказ и отправляет счёт. Состояние: «ожидает оплаты». Права нет.
- 1 марта, через минуту. Приходит предварительная проверка. Бот видит заказ и подтверждает возможность оплаты. Права по-прежнему нет.
- 1 марта, ещё через мгновение. Приходит
successful_payment. Бот записывает платёж с его идентификатором и в той же транзакции переводит подписку в «активна» до 31 марта, ссылаясь на этот платёж. Затем выдаёт право и сообщает пользователю. - 1 марта, сбой. Допустим, процесс упал сразу после записи, до выдачи права. Тогда в подписке остаётся признак «право не выдано». Фоновая задача или повторно пришедшее уведомление доводит выдачу до конца и не продлевает срок второй раз.
- 12 марта. Пользователь отключает продление. Состояние: «отменена, но действует». Доступ работает до 31 марта.
- 31 марта. Срок вышел. Проверка при обращении отказывает в доступе, фоновая задача переводит подписку в «истекла». В журнале платёж по-прежнему «получен».
- Альтернатива: 5 марта пользователь просит вернуть деньги. Платёж помечается «в процессе возврата», выполняется возврат по сохранённому идентификатору, затем статус становится «возвращён», а подписка переходит в «возвращена» с закрытием доступа. Запись о платеже остаётся.
Видно, что на каждом шаге меняется либо журнал, либо состояние, либо оба, и на каждом шаге можно ответить на вопрос «почему так». Это и есть смысл разделения: журнал рассказывает о событиях, состояние говорит о правах.
Экран статуса: то, что видит пользователь
Всё сказанное выше в итоге должно уложиться в одно сообщение, которое пользователь получает по команде вроде /status. Хороший экран статуса отвечает на два вопроса: «до какого числа у меня доступ?» и «что с моим последним платежом?». Всё остальное (внутренние состояния, идентификаторы, служебные пометки) пользователю не нужно.
Вот как это может выглядеть для разных состояний:
Доступ активен до 31 марта.
Последний платёж: 1 марта, 100 Stars, получен.
Продление включено.
Доступ действует до 31 марта, продление отключено.
Последний платёж: 1 марта, 100 Stars, получен.
После этой даты доступ закроется.
Доступ закрыт.
Последний платёж: 1 марта, 100 Stars, возвращён 5 марта.
Оформить подписку можно заново.
Заметьте, как экран собирается из тех самых данных. Срок берётся из period_end в подписке, результат платежа из статуса в журнале, а признак продления из намерения пользователя. Если вы с самого начала хранили эти вещи раздельно, экран пишется за несколько строк. Если вы их смешали, вам придётся угадывать, что показывать, и угадывать будете именно тогда, когда пользователь пришёл с жалобой.
А когда человек напишет в поддержку «я заплатил, но у меня ничего нет», у вас будет не гипотеза, а запись: платёж с таким-то идентификатором получен в такое-то время, подписка находится в таком-то состоянии, право выдано или нет. С такой картиной разговор занимает минуту, и вместо споров о том, что было, вы занимаетесь тем, что нужно сделать.