тестовое задание: backend-разработчик (node.js).
мерчант создаёт счёт, платёжная система присылает webhook со статусом оплаты. подпись запросов, защита от повторов, зачисление строго один раз - деньги всё-таки.
стек: Node.js 22, TypeScript, Express 5, MongoDB (Mongoose), Redis (ioredis), Vitest + Supertest.
нужны Node.js >= 22, MongoDB в режиме replica set (без него не работают транзакции) и Redis. проще всего поднять инфраструктуру через docker-compose - само приложение не докеризовано, по условию задания:
docker compose up -d # mongo (single-node replica set) + redis
npm install
cp .env.example .env # дефолты уже рабочие для compose
npm run seed # создаст тестовых мерчантов, выведет id и webhookSecret
npm run dev # http://localhost:3000если mongo и redis уже крутятся локально - просто пропишите MONGO_URI / REDIS_URL в .env. но mongo обязан быть replica set'ом, хотя бы single-node.
npm test # unit + integration, 64 теста
npm run typecheckинфраструктура для тестов не нужна: mongo поднимается в памяти (mongodb-memory-server, single-node replica set), redis мокается (ioredis-mock - семантику SET NX EX я проверил по исходникам мока, она честная).
что покрыто:
- расчёт комиссии: граничные случаи округления, инвариант
fee + amountToReceive === amountна сетке значений - подпись: подделанное тело, чужой секрет, мусор в заголовке, подпись именно по сырым байтам (не по re-serialized JSON)
- replay: протухший timestamp (окно двустороннее), повторный nonce, попытка сжечь nonce неаутентифицированным запросом
- идемпотентность: повторная доставка paid-webhook -> одна запись в ledger, баланс зачислен один раз, второй ответ 200
- конкурентность: 10 параллельных webhook с одним nonce (ровно один проходит), 10 с разными nonce по одному счёту (ровно одно зачисление), гонка paid vs failed (ровно один терминальный статус, деньги консистентны со статусом)
конкурентные тесты я проверил на фальсифицируемость: временно подменял реализацию на наивную (find -> проверка статуса в js -> save) - тест падает с тройным зачислением. то есть он действительно ловит гонку, а не проходит всегда.
{ "amount": 10000, "currency": "USD", "merchantId": "<24-hex id>" }amount - целое в минорных единицах (копейки/центы). ответ 201:
{ "invoiceId": "...", "amount": 10000, "fee": 250, "amountToReceive": 9750, "currency": "USD", "status": "pending" }заголовки: X-Signature (hex HMAC-SHA256 от сырого тела, секрет мерчанта), X-Timestamp (unix-секунды), X-Nonce. тело: { "invoiceId": "...", "status": "paid" | "failed" }.
порядок проверок: подпись -> окно timestamp (±300с) -> уникальность nonce (redis SET NX EX) -> атомарный переход статуса. порядок не случайный: nonce - первая мутация состояния, и если жечь его до проверки подписи, любой неаутентифицированный запрос может заранее выжигать nonce'ы и DoS-ить легитимные доставки.
коды ответов: 200 - обработано или идемпотентный повтор; 401 - подпись; 400 - timestamp / nonce / валидация; 404 - неизвестный invoiceId; 500 - только транзиентные сбои. 500 тут намеренный: non-2xx для платёжной системы значит "ретрайни", и для упавшей базы это ровно то, что нужно.
текущий статус счёта и рассчитанные суммы.
деньги - только целые числа. все суммы в минорных единицах, комиссия мерчанта в базисных пунктах (250 = 2.50%), расчёт через bigint, ни одного float на денежных путях. 0.1 + 0.2 !== 0.3 - не та ошибка, которую хочется ловить в проде платёжки. округление комиссии half-up (обычный дефолт процессинга), правило зафиксировано и протестировано на границе .5. amountToReceive всегда получается вычитанием, не отдельным округлением - иначе инвариант суммы ломается.
зачисление ровно один раз - на уровне базы, не приложения. два слоя:
findOneAndUpdate({ _id, status: 'pending' })- атомарный guard: из N конкурентных webhook'ов переход pending -> paid выигрывает ровно один. никаких find -> if -> save, это классический TOCTOU.- переход статуса, запись в ledger (уникальный индекс по
invoiceId- вторая линия защиты) и$incбаланса идут в одной mongo-транзакции. падение между "пометили оплаченным" и "зачислили" деньги не теряет.
redis в зачислении не участвует, и это сознательно: он может потерять данные при рестарте или eviction, финансовая дедупликация живёт только в durable-хранилище. redis отвечает за транспортный replay-window, не за деньги.
дубликаты -> 200, не ошибка. платёжные системы ретраят любой non-2xx (Stripe - до 72 часов, переподписывая каждую попытку). ответить 409 или 500 на повторную доставку = устроить себе retry storm. поэтому повтор по уже терминальному счёту - 200 { idempotent: true }, дедупликация по invoiceId, не по подписи или nonce. конфликт (failed по уже оплаченному) - тоже 200 плюс warn в лог: терминальные статусы никуда не переходят.
TTL nonce >= окна timestamp. если nonce живёт меньше окна, появляется дыра: nonce истёк, timestamp ещё валиден - replay проходит. поэтому TTL = окно + запас на clock skew.
- мерчанты и их настройки (
feePercent,webhookSecret) создаются seed-скриптом - аутентификация и кабинет вне скоупа по условию feePercentхраню в базисных пунктах целым числом- валюты: USD / EUR / RUB (whitelist), без конвертации
- подпись по условию считается только от тела запроса. честно говоря, это дырка:
X-TimestampиX-Nonceкриптографически не защищены, перехваченное валидное тело можно переслать со свежим timestamp и новым nonce - все три проверки пройдут. фактическую защиту от двойного зачисления в такой схеме даёт идемпотентность на уровне счёта. в проде подписываютtimestamp.nonce.body(как Stripe:t=...,v1=...), тогда заголовки нельзя подменить отдельно от тела. сделал по условию, слабость отмечаю сознательно - повторный webhook со старым nonce по уже обработанному счёту получает 400 (replay), с новым nonce - 200 (идемпотентный ack). различаю транспортный повтор и повторную доставку
- подпись
timestamp.nonce.bodyвместо body-only (см. выше) + версионирование схемы подписи - rate limiting на /webhook до проверки подписи - HMAC на каждый запрос дешёвый, но не бесплатный DoS-вектор
- баланс выводить из ledger как из источника правды (сейчас это материализованное поле, на росте нагрузки я бы переехал на projection)
- структурированные логи (pino) и метрики вместо console
- healthcheck, graceful shutdown с drain'ом соединений mongo/redis
- CI с прогоном тестов (исключено условием задания)