Я делаю сервис по подписке и плачу налог как самозанятый. Пока платежей было три в день, чеки в «Мой налог» можно было выбивать руками. Когда они пошли круглосуточно, понадобилась автоматика — и выяснилось, что сложное в этой задаче не API налоговой, а один вопрос: что делать, когда запрос ушёл, а ответ не вернулся. Повторить — риск выдать человеку второй чек на ту же сумму. Не повторить — риск не выдать вовсе.Рассказываю, как устроена защита от двойного чека: почему у сетевого вызова три исхода, а не два, зачем флаг попытки взводится до обращения к ФНС одним атомарным запросом и кто подбирает платежи за упавшим процессом. Плюс два бага, которые я поймал уже после запуска: один мог привести ровно к тому второму чеку, от которого всё и строилось. Читать далее
Я делаю сервис по подписке и плачу налог как самозанятый. Пока подписка была бесплатной, вопросов не возникало. Как только пошли платежи, появилась обязанность, о которой мало кто думает заранее: на каждый поступивший рубль нужно выдать чек в «Мой налог», желательно в момент расчёта, а не через неделю.
Три платежа в день можно закрывать руками. Когда они приходят круглосуточно, включая четыре утра, ручная выдача превращается в постоянный риск: забыл — нарушение, выбил дважды — аннулируй и объясняйся.
Я это автоматизировал. Сама интеграция оказалась рутиной, а вот защита от второго чека на один платёж съела больше времени, чем всё остальное вместе, и именно про неё стоит рассказать. Заодно разберу два бага, которые я поймал уже после запуска — один из них ровно про то, как аккуратно построенный инвариант держится «почти всегда».
Почему это не решает платёжный провайдерЛогично ждать, что чек выбьет эквайринг. Но для плательщиков НПД эта возможность у ряда провайдеров отключена: в частности, ЮKassa убрала автофискализацию для самозанятых в конце 2025 года. Платёж проводят, а обязанность выдать чек остаётся на вас.
Публичного API у «Мой налог» тоже нет. Есть API личного кабинета lknpd.nalog.ru — тот, которым работает мобильное приложение. Он не документирован, и это надо держать в голове честно: контракт может измениться без предупреждения. Значит, вокруг него нужна обвязка, которая ломается предсказуемо и громко, а не молча.
Схема простая:
Запрашиваем СМС-код на телефон, привязанный к «Мой налог» — в ответ приходит challenge-токен.
Отправляем код вместе с идентификатором устройства — получаем короткий access-токен и длинный refresh.
Дальше живём на refresh: перед операцией проверяем срок access, при необходимости обновляем.
Идентификатор устройства генерируется один раз и хранится рядом с токенами: сессия привязана к устройству, его смена означает повторный вход по СМС.
Токен — это доступ к вашему налоговому кабинету, поэтому в базе он лежит только зашифрованным (Fernet, ключ снаружи приложения). Чтобы пережить переход с открытого хранения на шифрованное без ручной миграции, у зашифрованных значений есть префикс-маркер:
_ENC_PREFIX = "enc:"
def _dec(stored):
if stored is None:
return None
if not stored.startswith(_ENC_PREFIX):
return stored # старое значение, лежит открытым
return _fernet().decrypt(stored[len(_ENC_PREFIX):].encode()).decode()
Первая грабля: до ФНС надо ещё дозвонитьсяСервис работает в зарубежном дата-центре, и запросы к lknpd.nalog.ru оттуда вели себя как повезёт: то имя не резолвится, то соединение висит до таймаута.
Помогло подключение по заранее известному адресу с явным указанием имени хоста в TLS. То есть в момент запроса мы не зависим от DNS, но рукопожатие идёт с правильным SNI и сертификат проверяется как обычно — никакого «доверяем всему подряд»:
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
def __init__(self, host, ip, timeout):
super().__init__(host, timeout=timeout)
self._ip = ip
def connect(self):
sock = socket.create_connection((self._ip, 443), self.timeout)
sock.settimeout(READ_TIMEOUT) # таймаут и на чтение, не только на connect
self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
Плюс жёсткие сроки: 6 секунд на соединение, 12 на чтение. Налоговая не должна иметь возможности подвесить ваш поток.
Главное: у сетевого вызова не два исхода, а триНаивная реализация выглядит так: платёж подтверждён → зовём API → сохраняем номер чека. Ломается она на классике распределённых систем: запрос ушёл, чек создался, а ответ до нас не дошёл — оборвалась сеть, истёк таймаут на чтении, упал процесс.
Повторить — риск выдать человеку второй чек на ту же сумму. Не повторить — риск не выдать вовсе.
Тут и находится развилка, которую надо пройти осознанно один раз, а не решать по месту. Обычно код делит исходы на «получилось» и «не получилось», а их три:
точно не создан — соединение не установилось, повторять безопасно;
точно создан — пришёл ответ с идентификатором чека;
неизвестно — запрос ушёл, ответа нет. Повторять опасно, не повторять тоже неприятно.
Я выбрал сторону, которая не создаёт проблем ни клиенту, ни налоговой: ровно одна автоматическая попытка на платёж, а неопределённость эскалируется человеку. Третий исход существует в коде как отдельный тип ошибки, а не как «ну, наверное, не отправилось»:
class NpdUnreachable(NpdError):
"""Не подключились. Чек ТОЧНО не создан — повторять безопасно."""
class NpdMaybeSent(NpdError):
"""Запрос ушёл, ответа нет. Чек МОГ создаться — авто-повтор запрещён."""
В NpdMaybeSent попадают таймаут чтения, разрыв после отправки, 5xx и, что менее очевидно, HTTP-успех без идентификатора чека в теле: раз ответ не разобрался, считать операцию непроведённой нельзя.
Флаг попытки взводится до обращения к ФНС и одним запросом:
UPDATE payments
SET receipt_attempted = TRUE, receipt_status = 'pending'
WHERE id = :payment_id
AND receipt_attempted = FALSE
AND receipt_status = 'none'
AND status = 'succeeded'
AND applied = TRUE
Дальше смотрим на число затронутых строк. Одна — мы единственные, кто взял платёж в работу, идём в сеть. Ноль — попытку уже сделал кто-то другой, молча выходим. Никаких «сначала проверим, потом запишем»: между проверкой и записью успевает вклиниться параллельный обработчик, а у нас в этот платёж целятся сразу четверо — обработчик вебхука, кнопка «проверить оплату», фоновый догоняющий проход и, в перспективе, вторая реплика приложения.
Проверки succeeded и applied в том же условии — не украшение: чек выбивается только на платёж, который реально зачислен, и решается это тем же атомарным запросом, а не отдельным if строкой выше.
Состояния получаются такие:
Статус | Что означает | Кто может тронуть дальше |
|---|---|---|
| чека не было | автомат (один раз) |
| попытка идёт прямо сейчас | никто |
| чек есть, есть его идентификатор | человек (аннулирование) |
| чек точно не создан | человек (кнопка «выбить заново») |
| неизвестно, чек мог быть создан | только человек, сверка вручную |
Ключевое свойство: флаг попытки неубираемый. Даже если владелец снимет отметку о чеке и статус вернётся в none, автомат к этому платежу больше не подойдёт — он смотрит и на статус, и на флаг. Один платёж, одна автоматическая попытка за всю жизнь.
Если процесс умер между зачислением платежа и попыткой выбить чек, платёж останется с receipt_status = 'none', и никто про него не вспомнит. Этих подбирает фоновый проход, но у него узкое, намеренно скучное определение работы: брать только платежи с невзведённым флагом попытки — то есть выдать ровно ту одну попытку, которая не состоялась из-за падения. Это не «повторялка неудачных»: failed он не трогает никогда.
Второй сценарий — процесс упал во время самого вызова. Тогда платёж навсегда залипает в pending, а флаг уже взведён и чек мог создаться. Такие висяки тот же проход переводит в unknown и зовёт человека:
STUCK_PENDING_MIN = 10 # висит дольше — считаем, что процесс умер внутри вызова
Ещё одна деталь, которая экономит нервы при подключении: догоняющий проход смотрит только на платежи, оплаченные после даты подключения налогового кабинета. Иначе в первый же тик он бодро пойдёт выбивать чеки по всей прошлой истории.
Система нигде не «лечит себя сама» вслепую. В спорной ситуации она останавливается и пишет человеку, а человек за минуту смотрит в приложении «Мой налог», был чек или нет.
Баг, который нашёлся уже после запускаВсё выше я построил заранее и был собой доволен. А потом на ревизии кода нашлось вот что.
Захват платежа был атомарным. Ручная отметка была атомарной. Повтор был атомарным. Запись результата попытки — нет. Она выполнялась безусловно: сходили в ФНС, получили исход, записали.
Сценарий поломки: запрос к ФНС ушёл и подвис на минуту. Владелец за это время не стал ждать, проверил в «Мой налог», увидел чек и отметил платёж вручную — статус стал created. Ещё через несколько секунд наш медленный запрос вернулся с ошибкой и безусловно записал failed. Владелец видит «чек не пробит», честно жмёт «выбить заново» — и получает второй чек в ФНС на тот же платёж. Ровно то, против чего строился весь модуль.
Лечится одной строчкой в условии: писать итог, только пока статус всё ещё pending, то есть пока строку под нами не трогали.
changed = (db.query(Payment)
.filter(Payment.id == payment_id,
Payment.receipt_status == "pending") # ← строку под нами не меняли
.update(vals, synchronize_session=False))
db.commit()
А если changed == 0 и при этом мы успели создать чек — значит в ФНС их теперь два, и молчать нельзя: это налоги. Владельцу уходит прямое предупреждение «чек выбит автоматически, но платёж уже был отмечен вручную, проверьте, нет ли дубля».
Вывод, который я забрал себе: если у сущности несколько путей смены состояния, инвариант держится по самому слабому из них. Три перехода из четырёх были защищены, и этого хватало, чтобы считать задачу закрытой. Полезное упражнение — выписать все места, где состояние меняется, столбиком, и напротив каждого ответить, что будет при параллельной записи. Забытым оказывается обычно не «главный» путь, а тот, который выглядит как техническая запись результата.
Второй инцидент: вызов не в главном потокеОбращение к ФНС занимает секунды. У меня были все вызовы аккуратно вынесены в отдельный поток — кроме одного. Кнопка «проверить оплату» дёргала синхронизацию платежа напрямую в главном цикле приложения. Пока чеков не было, эта функция работала быстро и проблемы не создавала. Как только внутрь неё добавилась выдача чека, нажатие кнопки стало морозить весь сервис на секунды.
Отсюда два практических правила. Первое: обращение к внешнему API не живёт в главном цикле, только поток или очередь. Второе, менее очевидное: когда вы добавляете тяжёлую операцию внутрь существующей функции, проверьте все места, откуда её зовут. Быстрая функция могла позволить себе синхронный вызов, медленная — уже нет, и сломается это не там, где вы писали код.
Мелочи, которые всплыли по дорогеМомент расчёта. В чеке указывается время операции. Сервер живёт в UTC, самозанятый — в своём часовом поясе, и дата в чеке должна попадать в его сутки. Для подобранных «висяков» правильнее брать не текущее время, а время самой оплаты — чек должен отражать реальный момент расчёта, а не момент, когда о нём вспомнила автоматика.
Наименование услуги. Нужна человекочитаемая строка, а не «подписка id=42». Её увидит клиент, и она же попадёт в вашу отчётность.
Статус покупателя. Если платит физлицо, чек оформляется как доход от физлица и ставка НПД 4%. Появятся клиенты-юрлица и ИП — это отдельный путь: другой тип дохода, ИНН плательщика и ставка 6%. Закладывать заранее не обязательно, но знать про развилку стоит.
Аннулирование. В API есть отмена чека с указанием причины. Возвраты случаются, и лучше иметь кнопку, чем открывать приложение и делать руками.
Сбой чека не должен ломать оплату. Выдача чека висит после того, как подписка уже зачислена, в своей транзакции и своём try. Налоговая недоступна — клиент всё равно получил то, за что заплатил, а чек уходит в ручной разбор.
Механизм работает в бою несколько недель. Каждый успешный платёж автоматически выбивает чек, участие человека нужно только в спорных случаях, которых пока не было ни одного.
Главное, ради чего писал: в интеграциях, где повтор операции стоит дороже, чем её отсутствие, идемпотентность важнее надёжности доставки. Проще смириться с редким ручным разбором, чем автоматически наплодить дублей и потом аннулировать их по одному.
И если делаете что-то похожее — заведите различие между «точно не отправлено» и «может быть, отправлено» с первого дня. Дописать это потом, когда данные уже накопились, куда неприятнее, чем кажется.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Налоговики рекомендуют самозанятым проверить настройки авточеков, чтобы не переплачивать налог | 0 | 8.26 | 21-08-2026 |
| 2 | Налоговая доначислила почти 843 тысячи налогов и взносов за самозанятых | 0 | 11.24 | 18-08-2026 |
| 3 | Как я перенёс проверку цен с VPS на компьютеры пользователей — и зачем всё-таки оставил сервер | 0 | 7 | 06-08-2026 |
| 4 | ФНС хочет встроить функционал для уплаты налогов в интернет-агрегаторы для самозанятых | 0 | 0 | 21-11-2018 |
| 5 | Алгоритм был правильным. Ошибка была в контракте графа | -1 | 7.57 | 13-08-2026 |
| 6 | Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов | 5 | 8 | 07-07-2026 |
| 7 | ФНС готова интегрировать сервисы бесконтактной оплаты в приложение "Мой налог" | 0 | 0 | 27-03-2023 |
| 8 | Я делал свой продукт, пока работал в найме — и вот что получилось | 0 | 5 | 12-07-2026 |
| 9 | OAuth‑сервер, который не хранит пользователей | 0 | 8.24 | 28-07-2026 |
| 10 | ФНС: число самозанятых превысило 17 млн и продолжает расти | 0 | 19.17 | 21-08-2026 |