Skip to content

selimdev00/procyon

Repository files navigation

procyon - сервис приёма платежей

тестовое задание: 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) - тест падает с тройным зачислением. то есть он действительно ловит гонку, а не проходит всегда.

api

POST /invoice

{ "amount": 10000, "currency": "USD", "merchantId": "<24-hex id>" }

amount - целое в минорных единицах (копейки/центы). ответ 201:

{ "invoiceId": "...", "amount": 10000, "fee": 250, "amountToReceive": 9750, "currency": "USD", "status": "pending" }

POST /webhook

заголовки: 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 для платёжной системы значит "ретрайни", и для упавшей базы это ровно то, что нужно.

GET /invoice/:id

текущий статус счёта и рассчитанные суммы.

ключевые решения

деньги - только целые числа. все суммы в минорных единицах, комиссия мерчанта в базисных пунктах (250 = 2.50%), расчёт через bigint, ни одного float на денежных путях. 0.1 + 0.2 !== 0.3 - не та ошибка, которую хочется ловить в проде платёжки. округление комиссии half-up (обычный дефолт процессинга), правило зафиксировано и протестировано на границе .5. amountToReceive всегда получается вычитанием, не отдельным округлением - иначе инвариант суммы ломается.

зачисление ровно один раз - на уровне базы, не приложения. два слоя:

  1. findOneAndUpdate({ _id, status: 'pending' }) - атомарный guard: из N конкурентных webhook'ов переход pending -> paid выигрывает ровно один. никаких find -> if -> save, это классический TOCTOU.
  2. переход статуса, запись в 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 с прогоном тестов (исключено условием задания)

About

Payment acceptance service: invoices, signed webhooks, exactly-once crediting (Node.js + Express + MongoDB + Redis)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors