Перейти к основному содержимому

Оплата: устройство ЮKassa

apps/backend/src/modules/payment-provider-config, providers/payment-yookassa-runtime, api/hooks/payment/yookassa_yookassa.

Реестр и хранение реквизитов​

Реестр поддерживаемых провайдеров сейчас содержит один — ЮKassa (modules/payment-provider-config/registry.ts). Shop ID и secret key хранятся в базе, secret key — зашифрованным (crypto.ts, AES-256-GCM). PAYMENT_PROVIDER_CONFIG_ENCRYPTION_KEY обязателен и должен декодироваться из Base64 ровно в 32 байта. Наружу через Admin API отдаётся только признак «ключ задан», не сам ключ.

Для конфигурации сохраняются ID администратора, создавшего и последним изменившего реквизиты (models/payment-provider-config.ts:10).

(*) Режим «Тестовый/Боевой» ничего не переключает

Runtime-провайдер (providers/payment-yookassa-runtime/service.ts:137) использует только shop ID и secret key — сохранённое поле mode не читается вообще. Переключатель в админке — чисто декоративная подпись.

(*) Реквизиты нельзя очистить через Admin API

Пустой shop ID в форме выключает провайдера (storage.ts:78), но в записи остаётся прежнее значение — не null. Пустая строка для secret key схемой валидатора трактуется как «оставить прежний ключ» (api/admin/payment-providers/validators.ts:3), а не как удаление: null схема не принимает вовсе.

Попытка очистить shop ID или secret key через форму принудительно снимает флаг «включён для покупателей» (storage.ts:78) — отключение провайдера от чекаута гарантировано, даже когда сами реквизиты физически остались в базе (см. предупреждение выше: они не удаляются).

is_configured и is_enabled — два независимых состояния​

Провайдер видим покупателю только когда истинны оба признака (service.ts:61): is_configured (заданы shop ID и secret key) и is_enabled (администратор включил его). setPaymentProviderEnabledStep (steps/set-payment-provider-enabled.ts:45) не даёт включить ненастроенный провайдер — попытка выставить is_enabled=true без сохранённых shop ID и secret key отклоняется ошибкой на уровне workflow, до записи в базу.

Инициация платежа: /catalog/payment/*​

api/catalog/payment/initiate/route.ts, steps/prepare-catalog-online-payment.ts, steps/check-catalog-payment-status.ts, workflows/initiate-catalog-online-payment.ts.

  • Резервирование остатка перед сессией. До создания платёжной сессии backend проверяет inventory и резервирует остатки позиций корзины (workflows/initiate-catalog-online-payment.ts:125).
  • Повторная инициация не плодит дубли. POST /catalog/payment/initiate возвращает последнюю пригодную платёжную сессию и прежний return_url, если она ещё пригодна (steps/prepare-catalog-online-payment.ts:130). Если прежняя сессия непригодна, но резервы и payment collection сохранились, создаётся только новая сессия — без повторного резервирования остатка (catalog/payment/initiate/route.ts:80).
  • Сериализация. Параллельная инициация оплаты одной корзины сериализуется через locking-модуль (catalog/payment/initiate/route.ts:67) — гонка двух вкладок не создаёт две сессии.
  • return_url принимается только с origin из списка STORE_CORS (catalog/payment/initiate/route.ts:21).
  • POST /catalog/payment/status не требует customer-аутентификации — статус платежа любой корзины можно проверить, зная только cart_id (api/middlewares.ts:254). Это осознанное решение под гостевой чекаут, не дыра — id корзины и так нужен для остального API.
  • Ручная сверка и восстановление состояния. При проверке статуса ID платежа, сумма и валюта из ответа ЮKassa должны совпасть с локальной платёжной сессией — иначе финализация отклоняется (steps/check-catalog-payment-status.ts:37). Если ЮKassa сообщает об успешном списании, а локально авторизация или capture ещё не записаны, backend их создаёт по факту ответа провайдера (:105) — это спасает сценарий, где вебхук потерялся, но покупатель вручную обновил страницу оплаты.

Платёж​

Провайдер настроен на auto-capture, формирование чеков отключено (medusa-config.ts:83).

Удалённые статусы платежа маппятся так:

Статус ЮKassaЗначение
pendingожидание
waiting_for_captureавторизация
succeededуспех
canceledотмена
Выключение провайдера в чекауте не глушит операции с уже начатыми платежами

retrievePayment, capture, cancel, refund, а также deletePayment и updatePayment (service.ts:244,250) — продолжают работать с сохранёнными реквизитами даже после отключения способа оплаты для новых покупателей (service.ts:138,202). Все они одинаково подгружают актуальную runtime-конфигурацию перед вызовом базового провайдера. Выключен — значит «не предлагать новым», а не «остановить обработку существующих».

Возврат: идемпотентность и сверка​

Ключ идемпотентности возврата — SHA-256 от ID платежа, запрошенной суммы и уже возвращённой суммы, сокращённый до 48 hex-символов (service.ts:89). Повтор с теми же параметрами не создаёт второй возврат у провайдера.

Возврат в базе Medusa считается успешным только если ответ ЮKassa подтверждает, что общая возвращённая сумма на стороне провайдера выросла не меньше, чем на запрошенное значение (service.ts:222) — сверка, а не слепое доверие локальному состоянию.

Перед добавлением отрицательной order transaction возврата проверяется существующая запись с тем же ID возврата — защита от двойного зачисления при повторном вызове (steps/execute-catalog-order-refund.ts:56). Текст ошибки возврата нормализуется и обрезается до 2000 символов перед записью в metadata заказа.

Вебхук​

api/hooks/payment/yookassa_yookassa/route.ts — уведомления используются только как триггер: статус, сумма и session ID заново читаются из авторизованного API ЮKassa, а не берутся из тела уведомления (service.ts:264).

Проверка исходного IP (source-ip.ts):

  • X-Forwarded-For учитывается только для адресов из YOOKASSA_WEBHOOK_TRUSTED_PROXY_CIDRS.
  • Цепочка доверенных прокси разбирается справа налево — берётся первый адрес за границей непрерывной цепочки доверенных прокси (:116).
  • IPv4-mapped IPv6 приводится к IPv4, zone/scope suffix IPv6 отбрасывается перед проверкой CIDR (:14).
  • Корректный, но не поддержанный парсером формат адреса — fail-closed: считается недоверенным, вебхук не допускается (:53).

Идемпотентность: transaction ID детерминированно строится из типа события и payment ID — повторное уведомление того же события не применяется дважды (route.ts:41).

Коды ответа разделены по причине, чтобы провайдер повторял только то, что имеет смысл повторять:

КодПричина
403IP не прошёл проверку
400неверный формат уведомления
503временная ошибка на нашей стороне — просим ЮKassa повторить

Обрабатываются только события payment.* с валидным ID, session ID, суммой и известным удалённым статусом — остальные молча игнорируются (service.ts:256).

Границы асинхронности: два отдельных события​

Финализация заказа и возврат денег — это не прямые синхронные вызовы из вебхука, а два независимых события на event bus, каждое со своей retry-семантикой (см. event bus на Redis):

  • Вебхук публикует catalog.payment_status_check_requested (workflows/verify-catalog-payment-webhook.ts:11) — заказ финализирует отдельный подписчик catalog-payment-status-check.
  • Отмена заказа публикует catalog.order_refund_requested (workflows/cancel-catalog-customer-order.ts:29) — сам возврат денег выполняет отдельный подписчик, с общим event-bus повтором при ошибке провайдера.

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

Оформление заказа: нормализация данных​

place-catalog-order (steps/catalog-checkout.ts) — оформление завершением серверной корзины:

  • Email покупателя перед сохранением обрезается и переводится в нижний регистр (:96) — на нём строится сопоставление гостевых и аккаунтных заказов (см. Кастомные API), поэтому регистр и пробелы не должны создавать разные «личности».
  • billing_address записывается как копия одинакового нормализованного shipping_address (:232) — отдельного billing-адреса в чекауте нет, оба поля всегда совпадают.

Финализация заказа: блокировки и TTL​

ОперацияLock (что блокирует)Ожидание блокировкиTTL самого lockRetention состояния workflow
Финализация после оплатыпо cart ID30 сек2 мин3 дня
Возвратпо order ID30 сек2 мин90 дней
Отмена заказапо order ID30 сек2 мин3 дня
Истечение неоплаченной корзинысвоя lock30 сек2 минотдельно не задокументирована
Проверка платёжного вебхука———90 дней
Lock TTL и retention состояния workflow — разные величины

2 минуты — это TTL самой блокировки (сколько она держится, если процесс упал, не отпустив её явно), одинаковый у всех четырёх операций. Retention состояния workflow (3 дня / 90 дней) — отдельная настройка, сколько хранится история выполнения самого workflow для отладки и идемпотентных повторов. Не путайте эти две настройки при диагностике «зависшей» блокировки — снявшаяся через 2 минуты блокировка не означает, что состояние workflow тоже исчезло.

Заказ не создаётся повторно, если итог или валюта корзины на момент финализации отличаются от зафиксированных в платёжной сессии (steps/prepare-catalog-paid-order.ts:268). Повторный вызов финализации находит уже существующую связь cart→order и возвращает найденный заказ вместо повторного создания (:216) — идемпотентность на уровне workflow, а не только БД.

Перенос резервов остатка с корзины на заказ​

transfer-catalog-cart-reservations — состав резервов сверяется с позициями создаваемого заказа, после чего под блокировкой меняется только владелец резерва, без освобождения и повторного резервирования остатка (steps/transfer-catalog-cart-reservations.ts:93). Это ключевая защита от overselling именно в момент превращения оплаченной корзины в заказ — если бы резерв сначала освобождался, а затем резервировался заново, между этими шагами остаток мог уйти на другой заказ.

Неоплаченный платёж не живёт дольше 24 часов: почасовое задание expire-catalog-payment-cart освобождает корзину и резервы остатка (steps/expire-catalog-payment-cart.ts:13).

Использование промокода фиксируется отдельным шагом финализации​

register-catalog-promotion-usage (steps/register-catalog-promotion-usage.ts) — часть finalize-catalog-online-payment, идёт после подтверждения оплаты. Если у заказа были применены промокоды, шаг регистрирует расход бюджета кампании через PromotionModuleService.registerUsage; компенсация workflow при откате вызывает revertUsage с теми же вычисленными действиями. Если промокодов не было — шаг пропускает вызов сервиса целиком (computedActions.length === 0). Порядок важен: перенос резервов и создание заказа идут раньше — лимит кампании расходуется только на уже гарантированно созданный заказ, а не заранее.

Отмена заказа покупателем: cancel-catalog-customer-order​

Заказ отменяет либо возвращает — не всегда одно и то же действие. Workflow (под блокировкой по order ID, см. таблицу выше) идёт по стадиям:

  1. Подготовка (prepare-catalog-order-cancellation) определяет: уже отменён ли заказ, онлайн он или ручной, есть ли активные (не отменённые) fulfillment'ы и от какого они провайдера.
  2. Активные fulfillment'ы ApiShip отменяются у перевозчика (cancel-order-apiship-fulfillments) через провайдер-агностичный cancelOrderFulfillmentWorkflow core.
  3. Активные fulfillment'ы СДЭК отменяются так же, но только если возврат СДЭК не требуется (см. ниже) — СДЭК разрешает прямую отмену только до определённой стадии обработки заказа перевозчиком.
  4. Если СДЭК уже на стадии, где прямая отмена недоступна, вместо неё создаётся заявка на возврат (create-catalog-shipping-return-request) — отмена заказа как такового не происходит, вместо неё запускается процесс возврата СДЭК. Клиент получает не «заказ отменён», а «оформлена заявка на возврат».
  5. Только после (2)–(4) отменяется сам заказ: manual-заказы — стандартным cancelOrderWorkflow core, online-заказы — своей веткой (снятие резервов, отмена неподтверждённых платежей, отмена payment_collection, cancelOrdersStep).
  6. Если у online-заказа остаётся непокрытый возврат (уже захваченная оплата), публикуется catalog.order_refund_requested — сам возврат денег асинхронный (см. выше).
Любой не-ApiShip fulfillment (кроме случая возврата СДЭК) блокирует автоматическую отмену

Если у заказа активен fulfillment от провайдера, для которого нет отдельной ветки отмены — это означает, что заказ уже передан в обработку вне этого автоматического потока, и подготовка считает его не подлежащим автоматической отмене.

(*) Отказ перевозчика останавливает всю отмену, а не только свой шаг

cancelOrderApishipFulfillments (общий код для ApiShip и СДЭК) перехватывает любую ошибку провайдера и бросает MedusaError(NOT_ALLOWED) (steps/cancel-order-apiship-fulfillments.ts:31) — заказ остаётся неотменённым целиком, шаги (5)–(6) не выполняются. Покупатель видит generic-сообщение «Отмена сейчас недоступна» и должен повторить попытку позже; естественный повтор снова попытается отменить те же fulfillment'ы.

Связанное​

  • Резерв остатка при оформлении — стандартный inventory Medusa; модуль МойСклад в самих резервах не участвует, только читает уже посчитанные остатки.
  • Слияние гостевой корзины при входе намеренно обходит проверку остатка — см. Кастомные API, раздел store/*.