Оплата: устройство Ю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).
Коды ответа разделены по причине, чтобы провайдер повторял только то, что имеет смысл повторять:
| Код | Причина |
|---|---|
| 403 | IP не прошёл проверку |
| 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 самого lock | Retention состояния workflow |
|---|---|---|---|---|
| Финализация после оплаты | по cart ID | 30 сек | 2 мин | 3 дня |
| Возврат | по order ID | 30 сек | 2 мин | 90 дней |
| Отмена заказа | по order ID | 30 сек | 2 мин | 3 дня |
| Истечение неоплаченной корзины | своя lock | 30 сек | 2 мин | отдельно не задокументирована |
| Проверка платёжного вебхука | — | — | — | 90 дней |
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, см. таблицу выше) идёт по стадиям:
- Подготовка (
prepare-catalog-order-cancellation) определяет: уже отменён ли заказ, онлайн он или ручной, есть ли активные (не отменённые) fulfillment'ы и от какого они провайдера. - Активные fulfillment'ы ApiShip отменяются у перевозчика (
cancel-order-apiship-fulfillments) через провайдер-агностичныйcancelOrderFulfillmentWorkflowcore. - Активные fulfillment'ы СДЭК отменяются так же, но только если возврат СДЭК не требуется (см. ниже) — СДЭК разрешает прямую отмену только до определённой стадии обработки заказа перевозчиком.
- Если СДЭК уже на стадии, где прямая отмена недоступна, вместо неё создаётся заявка на возврат
(
create-catalog-shipping-return-request) — отмена заказа как такового не происходит, вместо неё запускается процесс возврата СДЭК. Клиент получает не «заказ отменён», а «оформлена заявка на возврат». - Только после (2)–(4) отменяется сам заказ: manual-заказы — стандартным
cancelOrderWorkflowcore, online-заказы — своей веткой (снятие резервов, отмена неподтверждённых платежей, отменаpayment_collection,cancelOrdersStep). - Если у online-заказа остаётся непокрытый возврат (уже захваченная оплата), публикуется
catalog.order_refund_requested— сам возврат денег асинхронный (см. выше).
Если у заказа активен fulfillment от провайдера, для которого нет отдельной ветки отмены — это означает, что заказ уже передан в обработку вне этого автоматического потока, и подготовка считает его не подлежащим автоматической отмене.
(*) Отказ перевозчика останавливает всю отмену, а не только свой шагcancelOrderApishipFulfillments (общий код для ApiShip и СДЭК) перехватывает любую ошибку
провайдера и бросает MedusaError(NOT_ALLOWED) (steps/cancel-order-apiship-fulfillments.ts:31)
— заказ остаётся неотменённым целиком, шаги (5)–(6) не выполняются. Покупатель видит
generic-сообщение «Отмена сейчас недоступна» и должен повторить попытку позже; естественный
повтор снова попытается отменить те же fulfillment'ы.
Связанное
- Резерв остатка при оформлении — стандартный inventory Medusa; модуль МойСклад в самих резервах не участвует, только читает уже посчитанные остатки.
- Слияние гостевой корзины при входе намеренно обходит проверку остатка — см.
Кастомные API, раздел
store/*.