Интеграция PayKeeper — экраны управления

Архитектурный сдвиг — PayKeeper account 1:1 ↔ ТТ

До migration paykeeper-adapter#007: account был привязан к ЮЛ (1 ЛК PK = 1 ЮЛ, N терминалов под ним). После: account привязан к ТТ (1 ЛК PK = 1 ТТ). У одного ЮЛ может быть N разных PK-аккаунтов — по одному на каждую ТТ под этим ЮЛ. Бизнес-кейс: разные ТТ ведут оплату через разные эквайринговые договоры/банки.

Все настройки PK переехали с экрана ЮЛ на экран ТТ. Старая вкладка «PayKeeper» в карточке ЮЛ — заглушка с указанием куда настраивать.

Интеграция PayKeeper (приём платежей + фискализация 54-ФЗ) живёт в карточке ТТ:

  • Карточка ТТ (/stores/:id) — секция «PayKeeper». Управление аккаунтом: креды, webhook URLs, привязанный mPOS-терминал, suspend/delete.

Отдельной top-level страницы нет — embed’ится в карточку ТТ по аналогии с Интеграции.md.


1. Вкладка «PayKeeper» в карточке ЮЛ

Где расположена

В карточке /legal-entities/:id — новая вкладка «PayKeeper» рядом с «Реквизиты», «Владелец», «Права», «ТТ».

Видна только если у пользователя integrations.read.

Состояние «не подключено»

Empty state:

  • Иконка + заголовок «Подключите PayKeeper»
  • Пояснительный текст: «PayKeeper принимает платежи и формирует фискальные чеки 54-ФЗ для заказов этого юрлица. Один ЛК PK = одно юрлицо.»
  • Кнопка «Подключить» (только при integrations.manage)
  • Ссылка «Как получить доступ к ЛК PayKeeper?» → tooltip с объяснением онбординга

Форма подключения

Модальное окно «Подключить PayKeeper»:

  1. Хост ЛК (pk_server_host) — text input, плейсхолдер example.server.paykeeper.ru. Валидация regex на домен *.server.paykeeper.ru.
  2. Логин (pk_login) — text input.
  3. Пароль (pk_password) — password input с кнопкой «глазик».
  4. Секретное слово (informer_seed) — password input с «глазиком». Hint: «Секретное слово указывается в ЛК PayKeeper → Настройки → Уведомления».
  5. Номер договора (paykeeper_id, опционально) — text input.

Ссылка «Как это заполнить?» → tooltip с инструкцией.

Кнопка «Подключить и проверить»:

  • Клиентская валидация.
  • Отправка POST /api/v1/admin/paykeeper/accounts.
  • Backend сразу делает тест-запрос GET /info/settings/token/ в PK → при успехе возвращает 201 + URL’ы; при ошибке возвращает 422 PK_CONNECTION_FAILED с текстом от PK.
  • При успехе — модалка «Интеграция создана» (см. ниже).
  • При ошибке — подсказка под формой с расшифровкой (неверные креды / таймаут / 500 от PK).

Модалка «Интеграция создана»

После успеха — блокирующая модалка (не даёт пропустить) со следующим содержанием:

Подключено ✓

Скопируйте 3 webhook URL в настройки ЛК PayKeeper:
Настройки → Уведомления

1. Успешная оплата:
   https://erp-test.nirbi.ru/pk-webhooks/informer/{account_id}
   [Копировать]

2. Возврат платежа:
   https://erp-test.nirbi.ru/pk-webhooks/refund/{account_id}
   [Копировать]

3. Чек 54-ФЗ (опционально):
   https://erp-test.nirbi.ru/pk-webhooks/receipt/{account_id}
   [Копировать]

Также укажите в ЛК тот же "informer_seed" что вы ввели выше.

Кнопка «Готово, всё настроил».

Состояние «подключено»

Карточка интеграции:

┌──────────────────────────────────────────────────────────┐
│ 🟢 PayKeeper                                  [активен]  │
│                                                          │
│ ЛК:                example.server.paykeeper.ru           │
│ Логин:             admin                                 │
│ Номер договора:    140221-031-1                          │
│ Подключено:        23.04.2026, 10:00                     │
│ Привязано ТТ:      3 из 5                                │
│ Последний токен:   23.04.2026, 14:30                     │
│                                                          │
│ [Webhook URLs] [Журнал] [Проверить] [Редактировать]      │
│                                                          │
│ [Приостановить] [Удалить]                                │
└──────────────────────────────────────────────────────────┘

Кнопки (видимость по permission):

КнопкаКто видитДействие
Webhook URLsintegrations.readМодалка с 3 URL и «Копировать»
Журналintegrations.readПереход к подвкладке «Журнал» (см. ниже)
Проверитьintegrations.managePOST /.../test-connection → toast «Соединение работает» / «Ошибка: …»
Редактироватьintegrations.manageМодалка редактирования (те же поля что при создании; пароль и seed не pre-fill, обновляются только если введены)
Приостановитьintegrations.manageМодалка подтверждения, POST /.../suspend
Возобновить (видна если suspended)integrations.managePOST /.../resume
Удалитьintegrations.manageМодалка подтверждения. Если есть открытые инвойсы — блок и список

Блок «Каталог в PayKeeper» (BR 3.4)

Отдельный блок внутри вкладки, ниже карточки интеграции.

Когда показан: только при status=active. При suspended / not_configured — скрыт полностью. Если интеграция есть, но suspended — выводится подсказка «Синхронизация каталога недоступна. Возобновите интеграцию, чтобы запустить sync.»

Mockup (active + успешный последний прогон)

┌─ Каталог в PayKeeper ──────────────────────────────┐
│  Товаров в ЛК PK:           142 ✓                   │
│  (из 47 товаров ERP × модификаторы)                 │
│                                                     │
│  Последняя синхронизация:  24.04.2026, 03:00        │
│  Триггер:                   ночной cron             │
│  Статус:                    ✓ Успешно               │
│                                                     │
│  [Пересинхронизировать]  [Журнал прогонов]          │
└─────────────────────────────────────────────────────┘

Счётчик Товаров в ЛК PK — это общее число товаров в каталоге PayKeeper, включая развёрнутые из модификаторов варианты и addon’ы. Категории и группы модификаторов как отдельные сущности в PK не синхронизируются — категории попадают в имя товара префиксом, модификаторы раскрываются в отдельные товары (правило развёртывания).

Отображение статуса последнего прогона (last_run.status)

СтатусВизуалПоведение
successЗелёная галочка ✓ «Успешно»
partialЖёлтый треугольник ⚠ «Частично (N ошибок)»Кликабельно → открывает «Журнал прогонов» с выделенной проблемной строкой
failedКрасный крест ✗ «Не удалось»Под статусом ссылка «Показать ошибку» → раскрывает last_error текстом
runningСиний спиннер + «В процессе… Mm:ss»Timer вычисляется как now - started_at. Polling 5 сек до перехода в финальное состояние
(нет прогонов)Серый текст «Ещё не синхронизировался, нажмите «Пересинхронизировать»»Кнопка «Журнал прогонов» скрыта

Индикатор расхождений

Если totals.diverged_count > 0 — дополнительная оранжевая строка под основной:

⚠ N товаров в ERP не синхронизированы в PayKeeper.
  Рекомендуется пересинхронизировать.

Tooltip на «⚠» уточняет: «Товар был изменён в ERP, но изменение не доехало до PayKeeper. Обычно происходит из-за проблем с сетью или временной недоступности PayKeeper API.»

Загрузка данных

  • GET /api/v1/admin/paykeeper/accounts/{id}/catalog-sync-status при монтировании вкладки.
  • Polling 30 сек если last_run.status != running (для своевременного обновления блока).
  • Polling 5 сек если last_run.status = running (для отображения прогресса).
  • При ошибке загрузки (500 / network) — fallback «Не удалось загрузить статус» с кнопкой «Повторить».

Кнопка «Пересинхронизировать»

ПараметрЗначение
ВиднаТолько при integrations.manage
АктивнаТолько при status=active интеграции и last_run.status != running
ActionPOST /api/v1/admin/paykeeper/accounts/{id}/resync-catalog

Поток:

  1. Клик → disable кнопки + spinner.
  2. При 202 Accepted → toast «Синхронизация запущена», блок переходит в running состояние (использует ответ sync_run_id и started_at), начинается polling 5 сек.
  3. При ошибках:
    • ACCOUNT_NOT_ACTIVE (422) → toast «Интеграция PayKeeper приостановлена. Возобновите, чтобы запустить синхронизацию».
    • SYNC_ALREADY_RUNNING (409) → toast «Синхронизация уже идёт, дождитесь завершения». Блок сам подтянет running-состояние при следующем poll.
    • FORBIDDEN (403) → toast «Недостаточно прав».

Кнопка «Журнал прогонов»

ПараметрЗначение
ВиднаПри integrations.read. Скрыта если прогонов не было.
ActionОткрывает модалку
Модалка «Журнал синхронизации каталога»

Таблица последних прогонов (GET /api/v1/admin/paykeeper/accounts/{id}/catalog-sync-runs?limit=20&since=...).

Колонки:

ВремяТриггерДлительностьСтатусPK-товаровОшибок
24.04, 03:00ночной cron8 сек✓ Успешно+2 −00
24.04, 10:15manual12 сек⚠ Частично+45 −02
23.04, 15:32webhook_missed3 сек✓ Успешно+1 −00
  • Формат счётчиков: +upserted −deleted (прочерк если оба 0: ).
  • Длительность = finished_at - started_at (для running — «идёт N с»).
  • Триггер:
    • cron → «ночной cron»
    • manual → «вручную»
    • webhook_missed → «восстановление» (tooltip: «Запущен автоматически, когда обнаружены несинхронизированные товары»)

Раскрытие строки → блок деталей:

Клик на строку раскрывает inline-блок (accordion):

  • GET /api/v1/admin/paykeeper/accounts/{id}/catalog-sync-runs/{run_id} → получить полный объект.
  • Если last_error не null → красная плашка с текстом.
  • Если errors_json не пуст → скроллируемый список (max-height 240px). Формат: {erp_product_id, variant_kind, variant_sku, message}. Рендерим как:
    • Пицца Маргарита 30 см (base) [abc-123:size-30]
      Rate limited by PK, retrying via outbox
    • Пицца Маргарита 30 см доп. Сыр Чеддер (free_addon) [abc-123:size-30:+cheese-chr]
      PK API returned 500
    
    Имя товара резолвится через кэш из catalog-sync-status.totals или дополнительный lookup (опционально).
  • Если ошибок нет — «Без ошибок» серым.

Пагинация:

  • 20 на страницу, кнопка «Загрузить ещё» внизу (использует since=lastSeenStartedAt).

Закрытие модалки — стандартная кнопка «Закрыть».

Подвкладка «Журнал»

Таблица последних 100 webhook-событий:

ВремяEndpointПодписьDedup keyОбработанЗаказ
14:32:10informer123456#001
14:35:02informer123457#002
14:40:15informer

Фильтры:

  • Endpoint: все / informer / refund / receipt
  • Только с ошибкой подписи (checkbox)
  • Период: последние 24ч / 7 дней / 30 дней

Клик по строке → модалка с полным raw body (JSON) и заголовками.

Пагинация: 20 записей, GET /api/v1/admin/paykeeper/accounts/{id}/logs?page=....

Состояние «приостановлено»

Карточка та же, но с красной меткой [приостановлено] и подсказкой «Новые платежи не принимаются. Существующие обрабатываются до закрытия».

Состояние «удалено»

Вкладка возвращается в состояние «не подключено». Исторические данные (paykeeper_payments, paykeeper_receipts) остаются в БД, но не видны через этот UI.


2. Секция «Терминал PayKeeper» в карточке ТТ

Где расположена

В карточке /stores/:id — новая секция «Терминал PayKeeper» (не отдельная вкладка, а секция на главной вкладке карточки ТТ).

Видна только если:

  • У ЮЛ этой ТТ активная PK-интеграция (paykeeper_accounts.status=active)
  • У пользователя integrations.read

Иначе: секция скрыта полностью (не empty state, а просто нет).

Состояние «не привязана»

Под заголовком секции:

⚠ Терминал PayKeeper не привязан
ТТ не может принимать оплату через PayKeeper до привязки терминала.
[Привязать терминал]   ← виден при integrations.manage

Форма привязки

Модальное окно «Привязать терминал PayKeeper»:

  1. ID терминала (pk_terminal_id) — text input, из ЛК PK.
  2. ID мерчанта (pk_mpos_merchant_id) — text input, из ЛК PK.
  3. Метка (label, опционально) — text input, плейсхолдер «Касса 1».

Кнопка «Привязать»:

  • POST /api/v1/admin/paykeeper/terminals с account_id (берётся автоматически из ЮЛ ТТ), store_id (из URL).
  • При 409 TERMINAL_STORE_EXISTS — «Эта ТТ уже привязана к другому терминалу».
  • При 409 TERMINAL_PK_ID_EXISTS — «Этот ID терминала уже используется другой ТТ».

Состояние «привязана»

✓ Терминал PayKeeper
ID терминала:   TERM-001
ID мерчанта:    MERCH-123
Метка:          Касса 1
Статус:         активен

[Редактировать] [Отвязать]     ← видны при integrations.manage

Менеджер ТТ (только integrations.read) видит это же, но без кнопок — только info.

Отвязка

Модалка подтверждения: «Отвязать терминал? ТТ перестанет принимать оплату через PayKeeper».

При наличии открытых инвойсов (pk_status IN (created, sent)) — блок: «Нельзя отвязать — есть N открытых инвойсов».


3. Секция «Фискальные атрибуты» в форме товара

Где расположена

В карточке товара (/catalog/products/{id}) — таб «Информация», новая подсекция «Фискальные атрибуты».

Видна всегда (не зависит от активности PK-интеграции — поля в БД всё равно обязательны).

Поля

ПолеТипDefaultОписание
Ставка НДС (vat_rate)selectvat20Без НДС (none) / 0% (vat0) / 10% (vat10) / 20% (vat20) / 10/110 (vat110) / 20/120 (vat120)
Предмет расчёта (payment_subject)selectgoodsТовар (goods) / Услуга (service) / Работа (work) / Подакцизный (excise) / Плата (payment) / Агентский (agency) / Составной (composite) / Иное (another) / Работа/услуга (job)
Способ расчёта (payment_type)selectfullПолный расчёт (full) / Предоплата (prepay) / Аванс (advance) / Частичная предоплата (partial_prepay) / Кредит (credit) / Оплата кредита (credit_pay) / Частичный расчёт (partial)

Hint под секцией: «Используются при формировании фискального чека 54-ФЗ через PayKeeper».

При миграции существующих товаров — default-значения выставляются автоматически, не блокируют сохранение старых записей.


4. Кнопка «Вернуть» в карточке заказа (PK-flow)

Где расположена

В карточке /orders/:id — кнопка «Вернуть» в шапке, видна при:

  • order.status IN (closed, delivered, ready/handed_over/in_delivery с paid_at)
  • Permission orders.refund
  • order.pk_payment_id != null (если null — legacy-возврат, другой flow)

Модалка возврата

  1. Radio: «Полный возврат» / «Частичный возврат» (default — полный)
  2. Если частичный — список позиций с чекбоксами и количеством (max = оригинал)
  3. Поле «Причина» (обязательное, textarea)
  4. Итого к возврату (вычисляется автоматически)

Кнопка «Вернуть»:

  • POST /api/v1/admin/orders/{id}/refund с payload (реализовано в Order Service — оно публикует order.refund_requested).
  • Подтверждение: «Возврат запущен. Обновление статуса в течение нескольких минут».
  • Toast с ID refund’а.

Отображение статуса возврата

В карточке заказа — секция «Возвраты»:

Возврат #1 — 500 ₽
Статус: в процессе...  ← или Выполнен / Ошибка
Инициирован: 23.04.2026, 15:00, Иванов И.И.
Причина: Клиент отказался от заказа

[Подробнее]

Polling GET /api/v1/admin/orders/{id} каждые 10 сек пока хотя бы один RefundRecord в status=started. При обновлении до done / failed — уведомление + перестать поллить.


5. Состояния и ошибки

Ошибки PK-интеграции

Код бэкаЧто показать
PK_CONNECTION_FAILED«Не удалось подключиться к PayKeeper: {msg от PK}»
ACCOUNT_EXISTS«У этого юрлица уже есть PK-интеграция»
TERMINAL_STORE_EXISTS«Эта ТТ уже привязана»
TERMINAL_PK_ID_EXISTS«Этот ID терминала уже используется»
ACCOUNT_HAS_OPEN_OPERATIONS«Нельзя удалить: есть открытые инвойсы/возвраты»
STORE_NOT_IN_SCOPE«ТТ не принадлежит юрлицу этого PK-аккаунта»

Ошибки возврата

Код бэкаЧто показать
REFUND_NO_PK_PAYMENT«У заказа нет платежа PayKeeper — возврат через legacy-процедуру» (с фолбэком на старый flow)
REFUND_ALREADY_FULL«Заказ уже полностью возвращён»
REFUND_AMOUNT_EXCEEDS«Сумма возврата превышает остаток по платежу»

Ошибки синхронизации каталога (BR 3.4)

Код бэкаЧто показать
ACCOUNT_NOT_ACTIVE«Интеграция PayKeeper приостановлена — возобновите, чтобы запустить синхронизацию»
SYNC_ALREADY_RUNNING«Синхронизация уже идёт, дождитесь завершения»
FORBIDDEN«Недостаточно прав для запуска синхронизации»
RATE_LIMITED (от Catalog Service /internal/catalog/full-snapshot)«Слишком много запросов — повторите через {retry_after} сек»
NOT_FOUND (на catalog-sync-runs/{run_id})«Прогон не найден»

6. Permissions — сводка

ДействиеPermission
Вкладка «PayKeeper» в ЮЛ виднаintegrations.read
Подключить / изменить / удалить PK-аккаунтintegrations.manage
Приостановить / возобновитьintegrations.manage
Секция «Терминал PK» в ТТ виднаintegrations.read
Привязать / отвязать / изменить терминалintegrations.manage
Проверить соединениеintegrations.manage
Фискальные атрибуты товара редактируютсяcatalog.edit (существующий, не новый)
Инициировать возвратorders.refund (существующий)
Просмотр журнала webhook’овintegrations.read
Блок «Каталог в PayKeeper» виденintegrations.read
Кнопка «Пересинхронизировать каталог»integrations.manage
Модалка «Журнал прогонов синхронизации»integrations.read
Кнопка «Выгрузить из PK» (импорт сотрудников) (BR 3.5)integrations.manage AND employees.edit
Журнал импортов сотрудников (BR 3.5)integrations.read

integrations.read и integrations.manageуже существуют (переиспользуются из Webhook-подписки и Агрегаторы). Новые permissions не заводим — для всех фич BR 3.4 / BR 3.5 хватает существующих + employees.edit для импорта сотрудников.


7. Ссылки