- PVSM.RU - https://www.pvsm.ru -
Я делаю сервис по подписке и плачу налог как самозанятый. Пока подписка была бесплатной, вопросов не возникало. Как только пошли платежи, появилась обязанность, о которой мало кто думает заранее: на каждый поступивший рубль нужно выдать чек в «Мой налог», желательно в момент расчёта, а не через неделю.
Три платежа в день можно закрывать руками. Когда они приходят круглосуточно, включая четыре утра, ручная выдача превращается в постоянный риск: забыл — нарушение, выбил дважды — аннулируй и объясняйся.
Я это автоматизировал. Сама интеграция оказалась рутиной, а вот защита от второго чека на один платёж съела больше времени, чем всё остальное вместе, и именно про неё стоит рассказать. Заодно разберу два бага, которые я поймал уже после запуска — один из них ровно про то, как аккуратно построенный инвариант держится «почти всегда».
Логично ждать, что чек выбьет эквайринг. Но для плательщиков НПД эта возможность у ряда провайдеров отключена: в частности, Ю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. Налоговая недоступна — клиент всё равно получил то, за что заплатил, а чек уходит в ручной разбор.
Механизм работает в бою несколько недель. Каждый успешный платёж автоматически выбивает чек, участие человека нужно только в спорных случаях, которых пока не было ни одного.
Главное, ради чего писал: в интеграциях, где повтор операции стоит дороже, чем её отсутствие, идемпотентность важнее надёжности доставки. Проще смириться с редким ручным разбором, чем автоматически наплодить дублей и потом аннулировать их по одному.
И если делаете что-то похожее — заведите различие между «точно не отправлено» и «может быть, отправлено» с первого дня. Дописать это потом, когда данные уже накопились, куда неприятнее, чем кажется.
Автор: AndreyGymDesk
Источник [1]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/python/455980
Ссылки в тексте:
[1] Источник: https://habr.com/ru/articles/1065464/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1065464
Нажмите здесь для печати.