Я делаю сервис по подписке и плачу налог как самозанятый. Пока подписка была бесплатной, вопросов не возникало. Как только пошли платежи, появилась обязанность, о которой мало кто думает заранее: на каждый поступивший рубль нужно выдать чек в «Мой налог», желательно в момент расчёта, а не через неделю.
Три платежа в день можно закрывать руками. Когда они приходят круглосуточно, включая четыре утра, ручная выдача превращается в постоянный риск: забыл — нарушение, выбил дважды — аннулируй и объясняйся.
Я это автоматизировал. Сама интеграция оказалась рутиной, а вот защита от второго чека на один платёж съела больше времени, чем всё остальное вместе, и именно про неё стоит рассказать. Заодно разберу два бага, которые я поймал уже после запуска — один из них ровно про то, как аккуратно построенный инвариант держится «почти всегда».
Почему это не решает платёжный провайдер
Логично ждать, что чек выбьет эквайринг. Но для плательщиков НПД эта возможность у ряда провайдеров отключена: в частности, Ю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. Налоговая недоступна — клиент всё равно получил то, за что заплатил, а чек уходит в ручной разбор.
Что в итоге
Механизм работает в бою несколько недель. Каждый успешный платёж автоматически выбивает чек, участие человека нужно только в спорных случаях, которых пока не было ни одного.
Главное, ради чего писал: в интеграциях, где повтор операции стоит дороже, чем её отсутствие, идемпотентность важнее надёжности доставки. Проще смириться с редким ручным разбором, чем автоматически наплодить дублей и потом аннулировать их по одному.
И если делаете что-то похожее — заведите различие между «точно не отправлено» и «может быть, отправлено» с первого дня. Дописать это потом, когда данные уже накопились, куда неприятнее, чем кажется.
Anvano
А в самом API на пробитие чека разве нет никакого ключа идемпотентности? Какого-нибудь UUID, при повторной отправке которого, сервис выдавал бы ошибку, если чек с таким UUID уже зарегистрирован?
Плотно работал с API облачных касс АТОЛ и Эвотор - там описанная вами ситуация (дублирование чека при повторном ТАКОМ ЖЕ запросе) невозможна в принципе. Если конечно не отправить чек с новым идентификатором.