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 или другой эквайринг — интересно, как решали задвоение чеков и обработку отказов сервиса «Чеки». Делитесь в комментариях.