Автоматическая выдача чека по 422-ФЗ: как мы чинили идемпотентность вебхуков ЮKassa раньше, чем взялись за сам чек

от автора

SmetaLegko — приложение для самозанятых мастеров (сантехники, электрики, отделочники), которое делает смету, счёт и приём оплаты по СБП и картой. Когда мы добавляли автоматическую выдачу чека, оказалось, что сама задача «выдать чек» — не самая сложная часть. Сложнее было не выдать его дважды.

Проблема

По 422-ФЗ (ст. 14, ч. 3) самозанятый обязан сформировать и передать клиенту чек в момент поступления оплаты, если она пришла электронно — СБП и карта подпадают под это требование. Штраф за просрочку — 20% от неучтённой суммы за первое нарушение, 100% при повторе в течение полугода. У наличных и банковского перевода срок другой (до 9-го числа следующего месяца), и это по-прежнему забота самого мастера — но для СБП/карты дедлайн жёсткий и в тот же день.

В первой версии платёжного флоу у нас всё заканчивалось на «счёт оплачен, отправили push». Чека не было вообще — просто выпало из спеки на этапе проектирования. Когда стали чинить, оказалось, что чинить нужно не с этого места.

Первая грабля: вебхук может прийти дважды

ЮKасса, как и большинство платёжных шлюзов, умеет — и периодически делает — повторную доставку webhook-уведомлений: таймауты на нашей стороне, сетевые ретраи и так далее. Без защиты от этого повторный payment.succeeded прогоняет всю цепочку побочных эффектов заново: amount_paid увеличивается второй раз, push уходит повторно, а после добавления автовыдачи чека — issueReceipt() вызывается дважды для одного и того же платежа. Второе — не просто баг, это отдельное нарушение: задвоенный чек на одну оплату сам по себе законодательная проблема, которую потом придётся аннулировать вручную через «Мой налог».

Поэтому реализацию мы намеренно упорядочили: сначала идемпотентность вебхука, и только потом — сама выдача чека. Обратный порядок означал бы, что первый же баг с ретраем ЮKassa создаёт задвоенный чек в проде.

Как устроена идемпотентность

Добавили таблицу с уникальным ограничением на связку provider + event_id:

CREATE TABLE webhook_events (

  id                CHAR(36) PRIMARY KEY,

  provider          VARCHAR(20) NOT NULL DEFAULT ‘yookassa’,

  event_id          VARCHAR(150) NOT NULL,   — id уведомления ЮKassa, не id платежа

  event_type        VARCHAR(50) NOT NULL,    — payment.succeeded и т.д.

  processed_at      DATETIME NOT NULL,

  created_at        DATETIME,

  UNIQUE KEY uk_provider_event (provider, event_id)

);

Важный нюанс: уникальность строим по event_id — идентификатору самого уведомления, а не payment_id. Один платёж может породить несколько разных событий, и если бы мы завязались на payment_id, второе легитимное событие по тому же платежу просто не прошло бы.

Логика обработчика — первым шагом, до любой другой обработки:

1. Достаём event_id из тела уведомления ЮKassa

2. INSERT INTO webhook_events (provider, event_id, event_type, processed_at) …

3. Если insert упал на уникальном ограничении:

     → событие уже обработано, отвечаем 200 и ничего не делаем

4. Если insert прошёл:

     → продолжаем обычную обработку платежа

Просто, но именно эта простая проверка убирает целый класс проблем — включая ту, из-за которой мы вообще сюда пришли.

Как выглядит сама выдача чека

В invoices добавили поле статуса чека:

chek_status               ENUM(‘NOT_REQUIRED’,’PENDING’,’ISSUED’,’FAILED’)

                           NOT NULL DEFAULT ‘NOT_REQUIRED’,

chek_issued_at             DATETIME NULL,

chek_yookassa_receipt_id   VARCHAR(100) NULL,

NOT_REQUIRED — статус по умолчанию, потому что наличные и банковский перевод (записанные вручную через POST /invoices/:id/payments) живут по другому дедлайну и не требуют от приложения немедленной реакции. Статус переходит в PENDING только когда платёж прошёл через ЮKassa — то есть именно в категории с дедлайном «в тот же день».

Сама обработка payment.succeeded (уже после проверки идемпотентности):

— отмечаем Payment COMPLETED, пересчитываем amount_paid/amount_due/status

— если user.tax_status == SAMOZANYATY:

    — invoice.chek_status = PENDING

    — если у мастера включена автовыдача (chek_auto_enabled):

        — вызываем YooKassaService::issueReceipt(invoice)

        — успех  → chek_status = ISSUED, chek_issued_at = now()

        — ошибка → chek_status = FAILED, логируем, но пуш о платеже всё равно отправляем

    — иначе:

        — отправляем push с прямой ссылкой на оформление чека в «Мой налог»

issueReceipt() дергает «Чеки» — отдельный платный сервис ЮKassa, который мастер включает у себя в личном кабинете (мы это не настраиваем за него, только вызываем, когда добавок уже включён). Это осознанный выбор: не строить свою фискализацию с нуля, а переиспользовать то, что у ЮKassa уже сертифицировано и работает.

Если автовыдача не подключена

Не все мастера захотят платить ЮKassa комиссию за автоматические чеки (около 1,5% за СБП, 0,4% за прочие способы). Для них — не тишина, а push с конкретной суммой и датой и deeplink прямо на создание чека в приложении «Мой налог», либо ссылка в браузер, если приложения нет:

«Не забудьте выдать чек» — «Сегодня нужно сформировать чек в Мой налог на ₽{сумма} от {клиент}»

И отдельная ветка на случай, если сама автовыдача не сработала (например, добавок у мастера технически включён, но ЮKassa всё равно отклонила вызов) — chek_status = FAILED, банер в приложении с прямой инструкцией, что делать руками.

Что в итоге

Порядок разработки оказался важнее самой фичи: идемпотентность вебхука — скучная инфраструктурная задача, но именно она защищает от того, чтобы красивая автоматизация чека превратилась в источник задвоенных чеков в первую же неделю продакшена. Сейчас SmetaLegko можно попробовать бесплатно и без регистрации — RuStore, Google Play, App Store и в браузере.

Если кто-то тоже разбирался с 54-ФЗ/422-ФЗ фискализацией через ЮKassa или другой эквайринг — интересно, как решали задвоение чеков и обработку отказов сервиса «Чеки». Делитесь в комментариях.

ссылка на оригинал статьи https://habr.com/ru/articles/1065664/