📚 BusinessPlatform Docs

BusinessPlatform — общий API магазина v1

Проект: BusinessPlatform · Создано: 15 октября 2026, 21:28:49 +03:00 · Обновлено: 16.10.2026 12:10 +03:00 · Статус: развёрнут выпуск 20261016-120451, код 9f4c685 через GitHub Actions/Packages; 227 Commerce checks и 26 живых запросов прошли.

Это общий service-to-service API BP. Velvio — первый клиент. BP хранит каталог, доступный остаток и заказы, рассчитывает сумму и принимает подтверждённый результат оплаты от приложения. BP не получает ключ ЮKassa, не создаёт платёж и не обращается к платёжному провайдеру в этом контуре. Прежние интеграционные /payment-settings и /checkout возвращают 410 payment_owned_by_client; самостоятельные платёжные каналы BP этим документом не заменяются.

Основание: решения владельца 7 октября об общем API и 15 октября о владении оплатой Velvio; актуальный контракт SiteCerator docs/architecture/BUSINESSPLATFORM-INTEGRATION.md, CommerceModels/IBusinessPlatformGateway; код Services/Commerce/, миграция 039 и проверки tools/CommerceChecks.

1. Подключение и границы

Production host: https://app.irisaicrm.shop.

Базовый путь: /api/v1/commerce/projects/{projectId}. Далее в примерах BASE — полный адрес с UUID проекта Velvio. Это внешний projectId, не businessId BP. Аутентифицированный API-клиент определяется по проверенным issuer/key/subject JWT, а не по полю запроса. Привязка ищется по clientId + projectId. Одинаковые projectId разных API-клиентов не дают доступ к одному бизнесу. Явное подключение нескольких приложений к одному бизнесу пока не реализовано.

GET /catalog предназначен для публичного сайта через сервер приложения. В браузер не передаются JWT/приватный ключ BP. Velvio проверяет владельца проекта для команд кабинета, определяет проект по собственному siteKey для публичных действий, выполняет CAPTCHA/лимиты и проверку публикации. Business.IsPublished=false не мешает доверенному S2S чтению: публикацией сайта Velvio управляет сам Velvio. Public CORS у этого BP API отсутствует.

Порт ядра на сервере — 127.0.0.1:5168; внешнее подключение только HTTPS через nginx. Basic Auth кабинета не применяется к /api/v1/commerce/, но JWT обязателен. Не использовать прямой HTTP-порт как внешний base URL.

2. Авторизация и настройки

Authorization: Bearer <short-lived RS256 JWT>
X-Request-Id: <UUID>
Idempotency-Key: <unique command key>
Content-Type: application/json

JWT: aud=business-platform, доверенный iss, соответствующий зарегистрированному клиенту sub, известный kid, непустой UUID jti, Unix iat/exp. RSA не менее 2048 бит; exp−iat не более 300 секунд; допуск часов 30 секунд. Приватный ключ хранится только у клиента, BP получает публичный ключ.

Permission Возможности
commerce:provision Создать привязку проекта/бизнес
commerce:read Каталог, варианты, способы получения, заказы
commerce:write Изменения каталога/остатка/доставки, создание и рабочие статусы заказов
commerce:payments Передать проверенный результат оплаты

Scopes перечисляются через пробел. Для существующего клиента Velvio поддерживаются iss=velvio, sub=velvio-api, kid=velvio-production-20261008, scopes velvio:read, velvio:write, velvio:provision. Только у явно настроенного legacy-клиента velvio:write также разрешает передачу результата оплаты. Для новых клиентов использовать отдельный commerce:payments.

Существующие настройки SiteCerator: BusinessPlatform:BaseUrl=https://app.irisaicrm.shop, BusinessPlatform:KeyId=velvio-production-20261008, BusinessPlatform:PrivateKeyBase64 либо PrivateKeyPem — уже установленный секрет issuer. Новый общий секрет и копирование приватного ключа на BP не требуются. Новый HTTP-шлюз выбирает путь /api/v1/commerce/projects/…; старый BusinessPlatformClient использует прежний путь и сам по себе новый контракт магазина не включает. Код SiteCerator в этой задаче не изменяется.

Защищённая конфигурация BP, без действующих ключей в Git:

{
  "CommerceApi": {
    "Enabled": true,
    "Clients": [{
      "Id": "velvio",
      "Issuer": "velvio",
      "Subject": "velvio-api",
      "KeyId": "velvio-production-20261008",
      "PublicKeyBase64": "<public PEM encoded as base64>",
      "LegacyVelvioScopes": true
    }]
  }
}

Реестр поддерживает несколько независимых клиентов с разными issuer/subject/kid и ключами. Одновременно несколько ключей одного clientId пока не поддерживаются; ротацию согласовать с коротким временем жизни токенов. Без ключей включённый API не стартует. Из коробки API выключен. Секреты/полный docker inspect не переносить в справку.

3. Методы

Все пути таблицы добавляются к BASE. Все команды, включая DELETE, требуют Idempotency-Key; POST/PUT/PATCH — application/json, DELETE — пустое тело. Удаление справочников мягкое. PUT заменяет редактируемый DTO, не является частичным PATCH.

Метод и путь Результат
PUT базового адреса, без суффикса Provision: 201 первый раз, 200 для существующего бизнеса; не изменяет его тип
GET /capabilities Реализованные операции; paymentCreation=false, externalPaymentStatus=true
GET /store Каталог владельца: categories/products, включая черновики; provider=businessplatform
GET /catalog Активные товары, доступные активные варианты, категории и способы получения
GET /catalog/products/{id-or-slug} Одна публично доступная карточка
GET /products/{id} Карточка владельца с вариантами
GET/POST /categories Список/создание категории; создание 201
PUT/DELETE /categories/{id} Изменить/архивировать категорию
POST /products Создать товар с вариантами, 201
PUT/DELETE /products/{id} Заменить карточку и набор вариантов / архивировать товар
POST /products/{id}/variants Добавить вариант, 201; ответ — обновлённая карточка товара
GET /variants/{id} Получить вариант
PUT/DELETE /variants/{id} Изменить/архивировать вариант; ответ — обновлённая карточка
PUT /variants/{id}/stock Установить доступный для новых заказов остаток
GET/POST /delivery-methods Список/создание способа получения
PUT/DELETE /delivery-methods/{id} Изменить/архивировать способ
GET /orders Заказы проекта; фильтр status
GET /orders/{id} Актуальное состояние заказа
POST /orders Создать заказ и резерв одной транзакцией, 201
PATCH /orders/{id} Допустимый рабочий переход статуса, 200
POST /orders/{id}/payment-status Принять проверенный результат оплаты, 200

Списки товаров/заказов: limit=1..100, offset≥0; ответ содержит total/offset/limit/nextCursor. Вместо offset можно передать cursor из предыдущего ответа (URL-encode); одновременно cursor и offset запрещены. Это курсор позиции в текущей выборке, не snapshot: конкурентные изменения могут потребовать повторного обхода. Простой клиент может продолжить использовать offset, как нынешний DTO SiteCerator.

Каталог: search, category (UUID или slug), sort=default/newest/name/price_asc/price_desc. Категории и способы получения возвращаются массивами. siteKey в owner store пустой: непрозрачный публичный ключ сайта принадлежит Velvio и подставляется его шлюзом. Заказы содержат {orders,total,offset,limit,nextCursor}.

4. DTO каталога и доставки

Provision:

{"name":"Магазин Пример","businessType":"retail","timeZone":"Europe/Moscow"}

Новый бизнес поддерживает retail либо hybrid; создаётся неопубликованным, в RUB. Для старого проекта используется существующая разрешённая привязка. Новый проект получает бесплатный «Самовывоз»; платную доставку нужно явно настроить.

Категория:

{"name":"Одежда","slug":"clothes","sortOrder":0,"active":true}

Создание/полная замена товара:

{
  "categoryId": null,
  "name": "Футболка",
  "slug": "shirt",
  "description": "Хлопок",
  "status": "active",
  "images": ["https://example.test/shirt.jpg"],
  "attributes": {"Материал":"Хлопок"},
  "sortOrder": 0,
  "variants": [{
    "id": null,
    "sku": "SHIRT-RED-M",
    "name": "Красная, M",
    "priceRub": 1900,
    "compareAtPriceRub": 2100,
    "stockQuantity": 7,
    "trackStock": true,
    "attributes": {"Цвет":"Красный","Размер":"M"},
    "active": true,
    "sortOrder": 0
  }]
}

Ответ CommerceProductDto: id/categoryId/name/slug/description/status/images/attributes/sortOrder/variants/updatedAt. Вариант: id/sku/name/priceRub/compareAtPriceRub/stockQuantity/trackStock/attributes/active/sortOrder. Поля соответствуют CommerceModels SiteCerator.

Статусы карточки: draft/active/discontinued; archived получается при DELETE. Public каталог скрывает черновики, отключённые/архивные категории, отключённые варианты и варианты без доступного остатка при trackStock=true. Для PUT существующие варианты передаются со своими id; новые — с id=null. Отсутствующие варианты архивируются, ссылки заказов сохраняются. ID другого товара/бизнеса не принимается. Последний вариант удаляется вместе с карточкой, отдельный DELETE последнего варианта возвращает 409 last_variant.

SKU: латинские буквы/цифры/точка/дефис/подчёркивание, до 100 символов; уникален без учёта регистра в бизнесе для управляемых этим контуром SKU. Slug — латиница/цифры/дефисы. До 100 вариантов, 20 изображений HTTP/HTTPS, 40 характеристик. Деньги — RUB, неотрицательные, максимум два знака после запятой; поле цены от браузера в заказ не принимается. Основная цена — priceRub, compareAtPriceRub только для отображения.

Остаток:

{"stockQuantity":12,"trackStock":true}

Это доступный остаток, уже за вычетом сохранённых заказов. Новое число задаёт администратор, оно не прибавляется к старому. trackStock=false означает отсутствие ограничения; в БД stock_quantity=NULL, числовое значение DTO при этом не используется для продаж. Это не полный склад: приходы, движения и инвентаризация ещё не реализованы.

Способ получения:

{"type":"delivery","name":"Курьер","description":"По городу","priceRub":350,"active":true,"sortOrder":10}

Тип pickup либо delivery. Цена доставки берётся только из сохранённого способа. Его имя/стоимость и адрес сохраняются снимком в заказе: изменение или архивирование способа не меняет старые заказы.

5. Заказ, резерв и рабочие статусы

{
  "items":[{"variantId":"<UUID>","quantity":2}],
  "customer":{"name":"Анна","phone":"+79990000000","email":null},
  "deliveryMethodId":"<UUID>",
  "address":"Москва, улица, дом",
  "comment":"Позвонить перед доставкой"
}

От 1 до 50 строк, количество SKU 1..100 после объединения повторных variantId. Нужны имя и хотя бы телефон/email; для delivery — адрес. Продукт, вариант, категория, доставка и доступный остаток проверяются на сервере. Предварительные цены/названия/суммы из браузера не принимаются: лишние поля JSON отклоняются.

CommerceOrderDto: id/number/status/paymentStatus/subtotalRub/deliveryRub/totalRub/customer/delivery/address/comment/items/createdAt/updatedAt. Строка заказа: productId/variantId/productName/variantName/sku/quantity/unitPriceRub/totalRub. number — номер BP, id — UUID заказа. Заказ 2×1900 + доставка 350 возвращает totalRub=4150.

Создание заказа сериализует команды бизнеса, блокирует SKU, уменьшает доступный stock_quantity и записывает reserved для отслеживаемых SKU. Заказ, позиции, резерв, событие и сохранённый HTTP-ответ — одна SQL-транзакция. Внешних вызовов платёжного провайдера в этой транзакции нет. Подтверждение paid меняет резерв на consumed без второго уменьшения остатка. Отмена заказа либо подтверждённый полный refund возвращает зарезервированное количество один раз. Состояние released предотвращает двойной возврат при cancel → refund.

Резерв не имеет автоматического TTL в этом этапе. Неоплаченный заказ нужно явно отменить; политика автоматического закрытия — дальнейшая задача. Изменение всего товара через PUT также задаёт абсолютные текущие доступные количества вариантов; не отправлять устаревший снимок каталога при редактировании текста.

PATCH рабочего статуса: {"status":"processing"}. Допустимые переходы:

paid нельзя установить рабочим PATCH: только проверенным payment-status. Отмена оплаченного заказа не означает возврат денег: paymentStatus остаётся paid до подтверждённого refunded. Терминальные completed/canceled не переводятся в начальные состояния. Отмена платежной попытки и отмена заказа — разные операции.

Уточнение проверки 16 октября 2026: таблица выше уже действует в выпуске 20261015-212542. Обратные PATCH paid → new/awaiting_payment/confirmed запрещены. В выпуске 20261016-120451 (9f4c685) дополнительно закрыт приём PATCH paid → paid и запись commerce-заказов через старое IOrderStore; ограничения проверены 227 Commerce + 35 regression checks и развёрнуты через GitHub Actions/Packages. Нативные статусы старого кабинета не являются вторым интерфейсом изменения commerce-заказа.

Запрос HTTP / error.code Что сохраняется
Неизвестный status, пустой/null, неправильный регистр или строка с числом 400 / validation_error Ничего
PATCH с status=paid, включая повтор paid → paid в текущем выпуске 400 / validation_error Оплату подтверждает только payment-status
Известный status, но перехода нет в таблице 409 / invalid_transition Статус, платёж, остаток, outbox и квитанция не меняются
Повтор текущего рабочего статуса, кроме paid 200 Текущее состояние; повтор команды с прежним ключом возвращает исходную квитанцию
Старый кабинет пытается изменить commerce-заказ в текущем выпуске 409 / commerce_order_managed Использовать общий Commerce API; старые writers оплаты также блокируются в хранилище

Например, отказ paid → new возвращает {"error":{"code":"invalid_transition","message":"Order status transition is not allowed.","requestId":"<UUID>","details":{}}}. Для неизвестного значения code=validation_error, message=Unknown order status. После обновления PATCH paid возвращает тот же 400/code с message=Use payment-status to confirm payment. Клиент ориентируется на HTTP/code, не на текст message.

status описывает обработку заказа: new, awaiting_payment, paid, confirmed, processing, shipped, completed, canceled. paymentStatus отдельно описывает оплату: unpaid, pending, paid, canceled, refunded. Успешный paid переводит new/awaiting_payment в paid, но сохраняет уже начатый processing/shipped/completed. PATCH выполнения не меняет paymentStatus. Поэтому canceled/paid допустимо: заказ отменён, возврат ещё не подтверждён; completed/unpaid также возможен при выполнении до оплаты. Refunded — подтверждённая финансовая компенсация, она переводит заказ в canceled/refunded и освобождает резерв один раз, включая ранее completed. Это не повторное открытие заказа. Запоздалые pending/canceled не снимают paid; paid по прежнему paymentId после refunded не восстанавливает ни оплату, ни заказ.

6. Подтверждённый результат оплаты от приложения

{
  "eventId":"<stable UUID of this result>",
  "paymentId":"<payment/transaction ID from the trusted client>",
  "status":"paid",
  "amountRub":4150,
  "currency":"RUB"
}

Статусы: pending, paid, canceled, refunded. Для требуемого финального контура используются paid/canceled/refunded. До запроса Velvio самостоятельно проверяет результат у своего провайдера, сумму, валюту, магазин и привязку платежа к заказу. BP доверяет только аутентифицированному серверу с нужным scope; это не публичный webhook ЮKassa.

Amount/currency должны точно совпасть с сохранённым итогом BP. Partial refund в этом контракте не поддерживается. eventId стабилен при повторе результата; новый результат — новый eventId. Повтор eventId с другим телом/заказом — 409 payment_event_conflict. Повтор с другим HTTP Idempotency-Key не создаёт повторное изменение/событие.

События оплаты/заказа сохраняются в outbox без контактов покупателя. Для Velvio работает существующий HMAC worker/настройки приёмника; новый клиент без настроенной доставки не должен считаться подключённым к событиям. Доставка на независимые webhook-подписки всех API-клиентов ещё не реализована. Передача результата оплаты в BP является отдельным повторяемым шагом Velvio, а не общей транзакцией двух БД.

7. Повторы и ошибки

Idempotency-Key: один заголовок, 1..128 печатных ASCII символов без пробелов; уникален во всём client+project, а не только внутри одного endpoint. Hash включает точные method/path/rawBody. При timeout повторить те же байты с тем же ключом; JWT/requestId можно обновить. Изменённое тело с прежним ключом — 409 idempotency_conflict.

Успешный повтор возвращает первоначальные HTTP status/body, заголовок Idempotency-Replayed=true. Это квитанция первой команды, не актуальное состояние после последующих изменений. Для текущего состояния заказа использовать GET /orders/. Ошибки не сохраняются как успешные квитанции.

JSON UTF-8, camelCase, неизвестные/дублирующиеся поля запрещены. Максимум 64 KiB, глубина 16. Чтение до 5 секунд, команды до 15 секунд. Формат ошибки:

{"error":{"code":"stock_insufficient","message":"Not enough available stock.","requestId":"<UUID>","details":{}}}

400 validation/invalid_request/invalid cursor; 401 unauthorized; 403 forbidden/project_suspended; 404 project_not_found/entity_not_found; 409 stock_insufficient/delivery_unavailable/idempotency_conflict/entity_conflict/invalid_transition/payment_amount_mismatch/payment_event_conflict; 410 payment_owned_by_client для старого платёжного пути; 413 размер; 415 Content-Type; 503 integration_disabled/integration_unavailable; 504 timeout. Техническое message переводит клиент по code; SQL/credentials не возвращаются.

8. Хранение, проверка и ограничения

Таблица Что хранит
integration_tenants clientId/provider + внешний projectId → бизнес; статус доступа
integration_idempotency Hash запроса, исходный статус/ответ, одна транзакция с командой
commerce_products Карточка/категория/slug/изображения/характеристики; не отдельная копия SKU-цен
catalog_items Каждый вариант SKU: цена, доступный stock_quantity, характеристики, связь с карточкой
categories / commerce_delivery_methods Категории и способы получения бизнеса
orders / order_items Основные заказы BP и снимки строк
commerce_order_details Привязка заказа к клиенту/проекту, рабочий/платёжный статусы, снимок клиента/доставки
commerce_stock_reservations Сохранённое количество reserved/consumed/released для однократного возврата
commerce_payment_events Дедупликация подтверждённого результата, без реквизитов провайдера
integration_outbox / integration_webhook_deliveries События и доставка; контакты и платёжные секреты не включаются

PostgreSQL businessplatform на .182 — источник истины. Для этого контура Redis/RabbitMQ не требуются. Новый API не записывает платёжные реквизиты и не создаёт строку BP payments; исторические собственные платёжные данные BP автоматически не удаляются.

Первый выпуск рассчитан на headless-бизнесы с IsPublished=false для старых каналов BP. Старый checkout витрины не ведёт эти резервы; поэтому создание commerce-заказа для опубликованного в старых каналах бизнеса отклоняется с 409 legacy_channel_conflict. Публикация сайта Velvio при этом независима. Общий транзакционный checkout для собственных старых витрин/кабинета и нового API — дальнейшая работа; не включать оба checkout над одним остатком без неё.

Изолированная проверка использует schema-only fixture с PostgreSQL 17 и реальный TestServer/JWT/SQL. Не запускать seed или тесты на рабочей базе:

export DBI_CONN='Host=localhost;Database=bp_commerce_test;Username=postgres;Password=<test-only>'
export BP_COMMERCE_TEST_CONNECTION="$DBI_CONN"
dotnet run --project tools/DbInspector -- migrate tools/CommerceChecks/schema.sql
dotnet run --project tools/DbInspector -- migrate Migrations/039_commerce_api.sql
dotnet run --project tools/CommerceChecks

Read-only smoke на развёрнутом окружении выполнен у issuer с уже установленным JWT-ключом и scope velvio:read: anonymous capabilities → JSON 401; authenticated capabilities → 200; неизвестный проект → 404. Basic Auth кабинета не мешает, записи не создавались. Команда: dotnet run --project tools/CommerceReadOnlySmoke, ключ передаётся защищёнными переменными BP_COMMERCE_SMOKE_PRIVATE_KEY_BASE64 и BP_COMMERCE_SMOKE_KEY_ID.

227 commerce, 59 прежних integration и 35 regression checks прошли; три CI-задачи для master 9f4c685 зелёные. Товар/доставка/заказ/paid/refunded проверены на отдельной PostgreSQL со структурой рабочей БД. Квитанции: docs/development/commerce-deployment.json, commerce-production-readonly.json, commerce-production-smoke.json.

15 октября после прямого разрешения владельца write-smoke выполнен на развёрнутом API. Владелец уточнил, что коммерческого production ещё нет. Все 19 запросов прошли с настоящим JWT у issuer: создан отдельный неопубликованный проект, товар с остатком 7, доставка 350 ₽ и заказ 2 × 1900 + 350 = 4150 ₽. Повтор заказа вернул тот же ID. Остаток 7 → 5 при резерве, 5 после paid и 7 после полного refunded; повтор refund не увеличил остаток. Проверены чтение каталога/заказа/списка, переход processing и архивирование товара, категории и доставки. ЮKassa не вызывалась, деньги не списывались. Команда для повторной проверки создаёт новые тестовые записи:

export BP_COMMERCE_SMOKE_BASE_URL='https://app.irisaicrm.shop/api/v1/commerce/projects'
export BP_COMMERCE_SMOKE_KEY_ID='velvio-production-20261008'
# BP_COMMERCE_SMOKE_PRIVATE_KEY_BASE64 передать из защищённого хранилища issuer.
dotnet run --project tools/CommerceSmoke

После smoke товар/категория/доставка архивируются; тестовый заказ остаётся refunded для аудита. Его projectId/orderId выдаются итоговой JSON-квитанцией без токенов/секретов.

16 октября выпуск 20261016-120451 прошёл 26 живых запросов у issuer с BP_COMMERCE_SMOKE_STATUS_CHECKS=true: неизвестный статус — 400 validation_error; paid → new/awaiting_payment — 409 invalid_transition; PATCH paid → paid — 400 validation_error. Снимки тестового paid-заказа до и после четырёх отказов совпали, остаток сохранился 5. После проверок выполнен тестовый refund с остатком 7 и архивирование каталога; пользовательские заказы не использовались. Квитанции: commerce-state-deployment.json, commerce-state-production-smoke.json; digest ядра sha256:74522ebfe4c0a0a7c98c8f39051f4cecbfb4e321c024ffa4629b25e368fa0ac3. Workflow сборки Packages. Приватный ключ остался у issuer, временный env удалён.

Границы: общего календаря/записи в этом выпуске нет; полноценного складского журнала, TTL резервов, partial refund, общего доступа двух приложений к одному бизнесу и webhook-подписок по каждому клиенту ещё нет. Переключение SiteCerator с mock и перенос его существующих данных — отдельная работа шлюза; наличие BP API само по себе не переносит mock-заказы или публичные siteKey.

Импорт каталога через общий API

Серверный импорт YML, таблиц Yandex/WB/Ozon и Shopify CSV описан в отдельном контракте. Загрузка → предварительный разбор → явное применение → история; точные Job DTO, scopes commerce:imports:read/write, лимит 100 MiB, собственные изображения и миграция 040. Подтверждение развёртывания конкретного выпуска хранится отдельно; документация не заменяет release receipt.

Экспорт: GET /api/v1/commerce/projects/{projectId}/exports/shopify.csv и /exports/yandex.yml, scope commerce:read или commerce:imports:read. Синхронные UTF-8 файлы; необязательный baseUrl только для YML. Контракт и обратный импорт CSV.