PayKeeper (приём платежей + фискализация 54-ФЗ)

Источник требований

PayKeeper — внешний провайдер эквайринга и фискализации по 54-ФЗ. В ERP НИРБИ он заменяет собственный POS-стек (см. ADR-016): приём платежей, печать фискальных чеков, ОФД — всё на стороне PK. Наша интеграция — один микросервис erp-paykeeper-adapter (anti-corruption layer), вебхуки и набор команд к PK JSON API.

Это cross-cutting интеграция: затрагивает Юридические лица (один ЛК PK = одно юрлицо), Торговые точки (привязка к терминалу), Каталог (фискальные поля товаров), Заказы (flow оплаты и возврата).


Отличие от других интеграций

КейсСущностьСервис-владелец
Агрегаторы доставки (Яндекс.Еда, Market Delivery)OAuth2-binding, обмен меню и статусовAggregator Service
Webhook-подписки (внешние POS/KDS)Push событий наружуAggregator Service
PayKeeper (платежи + фискализация)Invoice flow, informer webhook, фискальные документыPaykeeper Adapter (новый сервис :3015)

PK — отдельный сервис, а не модуль Aggregator, потому что:

  • Direction двусторонний (мы шлём invoice, они шлют webhook) — в отличие от Aggregator (push-only).
  • Требует специфических auth-схем: Basic + token 24ч + MD5-подпись legacy + HMAC-SHA256 для receipt callback.
  • Нужен собственный outbox и multi-tenant routing по юрлицам.

Сущности

PK-аккаунт

Привязка одного ЛК PayKeeper к одному юрлицу франшизы.

ПолеОбязательностьОписание
idОбязательно (auto)UUID, первичный ключ
legal_entity_idОбязательно, UNIQUEFK → User Service legal_entities. Одно ЮЛ = один ЛК PK
pk_server_hostОбязательноURL ЛК вида {tsp}.server.paykeeper.ru
pk_loginОбязательноЛогин ЛК PK
pk_password_encОбязательноПароль, зашифрован AES-GCM at rest
informer_seed_encОбязательноСекретное слово для валидации MD5-подписи informer/refund webhook’ов. Зашифровано AES-GCM
paykeeper_idНеобязательноНомер договора PK (отображается в ЛК)
statusОбязательноactive / suspended (default active)
onboarded_atОбязательно (auto)Дата подключения
last_token_atНеобязательноПоследний рефреш токена
created_at / updated_atОбязательно (auto)

Привязка терминала

Per-ТТ маппинг на физический mPOS-терминал у PK.

ПолеОбязательностьОписание
idОбязательно (auto)UUID
account_idОбязательноFK → PK-аккаунт
store_idОбязательно, UNIQUEFK → Store Service stores. Одна ТТ = один терминал
pk_terminal_idОбязательно, UNIQUEЗаводской ID терминала, из ЛК PK
pk_mpos_merchant_idОбязательноID мерчанта у PK (обычно совпадает с ЛК)
labelНеобязательноЧеловекочитаемое имя («Касса 1»)
statusОбязательноactive / inactive
created_at / updated_atОбязательно (auto)

Инвойс

Запись о созданном счёте на оплату (ERP → PK).

ПолеОписание
idUUID
order_idFK на наш заказ
account_idFK на PK-аккаунт
pk_invoice_idID счёта у PK
pk_invoice_urlСсылка для оплаты
pk_statuscreated / sent / paid / expired
created_at, paid_at

Платёж

Запись informer success от PK.

ПолеОписание
idUUID
order_idFK (resolved из упакованного orderid)
pk_payment_idID платежа у PK
pk_unique_idУникальный ID транзакции у банка
pay_amountСумма
payment_system_idID платёжной системы PK
statuspaid (аналог у PK — success)
bank_idID привязки карты (если была)
pending_datetime, obtain_datetime, success_datetime
raw_informer_jsonПолный payload для аудита
received_at

Фискальный чек

Запись о чеке 54-ФЗ (догружается после оплаты).

ПолеОписание
idUUID
payment_idFK на наш платёж
pk_receipt_idID чека у PK
typesale / refund / expense / expense-refund
is_post_saleЧек окончательного расчёта (для delivery)
statuscreated / request_sent / success / timeout / failed / rejected
fpd, fnd, fnФискальный признак, номер ФД, ФН
rnkktРегистрационный номер ККТ
shift_number, receipt_numberНомер смены и чека в смене
fop_receipt_key, fop_urlСсылки на чек и QR-контент
tsВремя формирования
received_at

Возврат

Запись о возврате платежа.

ПолеОписание
idUUID
order_idFK
payment_idFK
pk_refund_idID возврата у PK
amountСумма возврата
statusstarted / done / failed
initiated_byadmin / pos — источник инициации
reasonПричина
datetimeДата выполнения

Статусы интеграции

stateDiagram-v2
    [*] --> not_configured: ЮЛ создано, PK не подключен
    not_configured --> active: Креды введены + проверены
    active --> suspended: Приостановлено админом
    suspended --> active: Возобновлено
    active --> [*]: Удалено (если нет активных заказов)
    suspended --> [*]: Удалено

Поведение по статусу:

СтатусМожно принимать оплату?Видно в админке
not_configuredНетСкрыто
activeДаПолный UI
suspendedНетRead-only, кнопка «Возобновить»

Бизнес-правила

  1. Один ЛК PK = одно юрлицо. paykeeper_accounts.legal_entity_id — UNIQUE. Нельзя использовать один ЛК PK для разных юрлиц сети.
  2. Одна ТТ = один терминал. paykeeper_terminals.store_id и paykeeper_terminals.pk_terminal_id — UNIQUE.
  3. ТТ без привязки не может принимать оплату через PK. При попытке создать invoice → 422 INTEGRATION_NOT_CONFIGURED.
  4. Шифрование секретов. pk_password, informer_seed хранятся AES-GCM. Ключ — в env PAYKEEPER_SECRETS_KEY (32 байта, из K8s/docker secret).
  5. Проверка подписи обязательна. Webhook с несовпадающим HMAC/MD5 → 400, заказ не закрывается.
  6. Dedup webhook’ов. По (account_id, pk_payment_id) для informer/refund и (account_id, pk_receipt_id) для receipt callback. Повторы идемпотентны.
  7. Удаление PK-аккаунта запрещено если есть открытые инвойсы (pk_status IN (created, sent)) или незакрытые возвраты (status=started).
  8. Приостановка ЮЛ каскадирует на PK-интеграцию. При legal_entities.status=suspendedpaykeeper_accounts.status=suspended автоматически.

Платёжный flow

Вариант 1. Оплата через терминал PK в ТТ (bar-flow)

  1. Кассир в POS собирает заказ → нажимает «К оплате»
  2. Order Service публикует order.payment_requested (заказ готов к оплате)
  3. Адаптер получает событие → вызывает POST /change/invoice/preview/ в PK → получает invoice_id, invoice_url
  4. Адаптер публикует paykeeper.invoice.created → Order Service сохраняет pk_invoice_id
  5. На терминале PK кассир видит заказ в очереди → нажимает «Принять оплату»
  6. Клиент платит (карта/QR/наличные)
  7. PK шлёт informer на наш webhook → адаптер валидирует MD5 → публикует paykeeper.payment.received
  8. Order Service закрывает заказ (status=paid, paid_at)
  9. Через ~30 сек адаптер догружает фискальные атрибуты (ФН/ФД/ФП/смена) → публикует paykeeper.receipt.fiscalized
  10. Order Service сохраняет фискальные данные для отчётов и налоговой

Вариант 2. Ссылка на оплату клиенту (online/delivery-flow)

Те же шаги, но после п.4 ссылка invoice_url отправляется клиенту (Telegram-бот / SMS / email). Клиент платит → дальше шаги 7-10.


Flow возврата

  1. Сотрудник с permission orders.refund нажимает «Вернуть» в карточке закрытого заказа
  2. Выбирает полный или частичный возврат, указывает причину
  3. Order Service публикует order.refund_requested
  4. Адаптер вызывает POST /change/payment/reverse/ в PK (с amount, partial, refund_cart)
  5. PK принимает запрос (async), отвечает {"result":"success"}
  6. Через N минут PK шлёт refund webhook → адаптер валидирует MD5 → публикует paykeeper.payment.refunded
  7. Order Service помечает возврат done, обновляет refund_records

Ошибки:

  • Возврат в PK отклонён → paykeeper.refund.failed с msg → админка показывает ошибку
  • Webhook не пришёл за 10 мин → reconciliation cron вручную проверяет статус через GET /info/refunds/bypaymentid/

Catalog Sync

Источник требований

Цель

Держать каталог в ЛК PayKeeper в синхронизированном состоянии с каталогом ERP. Нужно для двух сценариев:

  1. Offline / без нашего POS. Владелец хочет, чтобы Касса 3в1 от PK работала самостоятельно: кассир выбирает товары на терминале из ЛК PK, формирует счёт, печатает чек.
  2. Self-service через ЛК PK / embed-форма. Клиент (интернет-заказ, выставленная ссылка) выбирает товары сам через интерфейс PK.

В обычном платёжном flow (BR 3.3) каталог PK не используется — корзина передаётся в service_name.cart прямо в момент создания invoice. Catalog sync — отдельный независимый канал для сценариев где наш POS в цепочке отсутствует.

Что синхронизируется в ЛК PK

PayKeeper ims-api предоставляет только flat-список товаров — в нём нет понятий «категория», «группа модификаторов», «опция». Поэтому:

  1. Товары ERP → отдельные записи в PK.
  2. Категория товара → склеивается в name через префикс: "Кофе / Капучино".
  3. Модификаторы ERP → разворачиваются в отдельные товары в PK (подробности ниже).
  4. Image, tru_code, barcode, item_code_is_mandatoryне синхронизируются (ims-api их либо не поддерживает, либо Koala-прецедент подтвердил работу с null/false).

Правило развёртывания модификаторов

У нас два типа модификаторов:

  • Структурный (binding_type=structural) — обязательный выбор, ровно один вариант. Пример: размер пиццы 20/30/40 см, молоко (обычное/безлактозное/овсяное). Базовый товар не существует отдельно без структурного выбора.
  • Свободный (binding_type=free) — опциональная надбавка. Пример: доп. сыр к пицце.

Правило sync’а:

Состав товара ERPЧто отправляем в PK
Без модификаторов1 товар: "{categoryPrefix}{productName}"
Только свободные модификаторы (N опций)1 базовый + N addon-товаров: "{categoryPrefix}{productName}" + "{categoryPrefix}{productName} доп. {optionName}" каждый
Только структурный модификатор (M вариантов)M товаров без базового: "{categoryPrefix}{productName} {variantName}" для каждого
Структурный (M вар.) + свободные (N опц.)M × (1 базовый вариант + N addon’ов на него)

Пример 1 — Пицца Маргарита, структурный модификатор «Размер» (20/30/40см), свободный «Доп. сыр»:

Пицца / Пицца Маргарита 20 см
Пицца / Пицца Маргарита 30 см
Пицца / Пицца Маргарита 40 см
Пицца / Пицца Маргарита 20 см доп. Сыр Чеддер
Пицца / Пицца Маргарита 30 см доп. Сыр Чеддер
Пицца / Пицца Маргарита 40 см доп. Сыр Чеддер

Пример 2 — Айс Латте, только свободный модификатор «Молоко» (4 варианта):

Кофе / Холодное / Айс Латте
Кофе / Холодное / Айс Латте доп. Кокосовое молоко
Кофе / Холодное / Айс Латте доп. Безлактозное молоко
Кофе / Холодное / Айс Латте доп. Банановое молоко
Кофе / Холодное / Айс Латте доп. Овсяное молоко

Цена addon-товара = цена опции модификатора (доплата). Цена структурного варианта = базовая цена товара + доплата структурной опции.

Сущности

PK Product mapping

Привязка «наш виртуальный продукт ↔ товар в ЛК PK» в рамках одного PK-аккаунта. Одна запись mapping = один product в PK.

ПолеОбязательностьОписание
idОбязательно (auto)UUID
account_idОбязательноFK → PK-аккаунт
erp_product_idОбязательноНаш products.id (корневой товар из которого развернули)
erp_structural_option_idНеобязательноID структурной опции (modifier_options.id) если товар в PK представляет конкретный структурный вариант. Null для базового товара без структурного модификатора.
erp_free_option_idНеобязательноID свободной опции (modifier_options.id) если товар в PK представляет addon. Null для основного товара.
variant_kindОбязательноbase / structural_variant / free_addon (см. Правило развёртывания)
pk_product_idОбязательноID товара в ЛК PK (возвращается PK при upsert и используется для обратного webhook’а когда он появится)
skuОбязательноНаш человеко-читаемый ключ, кладётся в поле sku ims-api: "{erp_product_id}[:{structural_option_id}][:+{free_option_id}]". Используется для reverse lookup при обратном webhook’е.
hashОбязательноSHA-256 от сериализованного состояния (name + price + tax + kind-specific fields) — чтобы не слать если не менялось
statusОбязательноactive / deleted
last_synced_atОбязательноПоследний успешный push в PK
last_errorНеобязательноТекст последней ошибки
created_at / updated_atОбязательно (auto)

UNIQUE (account_id, erp_product_id, erp_structural_option_id NULLS, erp_free_option_id NULLS). UNIQUE (account_id, pk_product_id) — для reverse lookup при обратном webhook’е.

Почему три ERP-ID'а, а не один

Один erp_product_id из ERP раскрывается в N записей mapping’а (по формуле развёртывания выше). Чтобы в будущем, когда PK пришлёт reverse-webhook {pk_product_id, new_price}, мы могли однозначно найти какой именно вариант товара ERP он затрагивает — храним полную тройку (product_id, structural_option_id, free_option_id).

Sync Run

История полных прогонов для аудита. Без изменений относительно оригинальной схемы — счётчики теперь считают именно PK-товары (включая развёрнутые из модификаторов).

ПолеОписание
idUUID
account_idFK → PK-аккаунт
triggercron / manual / webhook_missed
started_at, finished_at
products_upserted, products_deletedСчётчики PK-товаров (базовые + развёрнутые варианты + addon’ы)
errors_count
statusrunning / success / partial / failed
errors_jsonДетали ошибок: [{entity_type, erp_id, erp_variant, message}]

Триггеры синхронизации

  1. Event-driven delta — основной путь. Catalog Service публикует Kafka-события при изменении товара (catalog.product.upserted / .deleted) или его модификаторов (catalog.modifier_group.upserted / .deleted → влияет на все товары где эта группа привязана). Adapter consume’ит, ре-генерирует полный список виртуальных продуктов для этого корневого товара (по правилу развёртывания), диффит против текущих paykeeper_products и кладёт нужные upsert/delete в outbox.
  2. Cron ночной re-sync — ежедневно в 03:00 MSK. Adapter идёт в Catalog Service за полным снепшотом (GET /internal/catalog/full-snapshot?franchise_id=X), разворачивает каждый товар по правилу, считает hash’и, находит расхождения, догружает. Safety-net на случай пропущенных webhook’ов.
  3. Manual re-sync — кнопка «Пересинхронизировать каталог» на вкладке PayKeeper в карточке ЮЛ. Запускает тот же механизм что и cron, но по требованию.

Flow синхронизации

sequenceDiagram
  participant CS as Catalog Service
  participant K as Kafka
  participant AD as Paykeeper Adapter
  participant PK as PayKeeper ims-api

  CS->>CS: сохраняет товар / модификатор
  CS->>K: catalog.product.upserted (или modifier_group.upserted)
  K->>AD: consume
  AD->>AD: resolve franchise_id → [active pk_accounts]
  AD->>CS: GET /internal/catalog/products/{id}/expand (полный расклад с модификаторами)
  AD->>AD: expand → list of virtual products
  loop для каждого pk_account × каждый virtual product
    AD->>AD: compare hash → нужен upsert / delete / skip?
    alt нужно слать
      AD->>AD: pk_outbox insert (upsert_product / delete_product)
      AD->>PK: POST /products  (или PATCH / DELETE)
      PK-->>AD: pk_product_id
      AD->>AD: upsert paykeeper_products + hash
    end
  end

Удаление товара в ERP → catalog.product.deleted → adapter помечает ВСЕ mapping-записи этого erp_product_id как status=deleted и шлёт delete в PK по каждой.

Подготовка к обратному webhook’у

Когда PK реализует reverse webhook каталога (ждём анонс от техподдержки, см. PK-summary-answers §6), он пришлёт {pk_product_id, new_fields...}. Adapter:

  1. Lookup paykeeper_products по pk_product_id → получает (erp_product_id, erp_structural_option_id, erp_free_option_id, variant_kind).
  2. Публикует в Kafka catalog.pk_upstream_changed с нормализованной нагрузкой.
  3. Catalog Service consume’ит → либо алерт админу, либо автообновление цены (business-logic TBD в будущем BR).

Уже сейчас мы храним обратный lookup-ключ (pk_product_id UNIQUE per account) + достаточно информации чтобы понять «что именно за вариант» ERP-товара PK имеет в виду. Конкретная обработка webhook’а — вне скоупа BR 3.4.

Бизнес-правила

  1. Тиражирование во все PK-аккаунты франшизы. Каталог ERP — на уровне франшизы, каталог PK — per-ЮЛ. Если у франшизы несколько юрлиц с подключённым PK, одно и то же изменение товара растиражируется в N ЛК PK.
  2. Единая цена на товар. v1 использует базовую цену products.price + доплаты модификаторов. Прейскуранты per-store (разные цены на разных ТТ внутри одного ЮЛ) в v1 не учитываются.
  3. Категории → в имя префиксом ("Кофе / Холодное / Айс Латте"). Отдельной сущности категорий в PK нет.
  4. Модификаторы → в отдельные товары (см. Правило развёртывания выше). Опции получают префикс имени корневого товара: "Пицца Маргарита 30 см доп. Сыр Чеддер".
  5. Картинки, tru_code, barcode, item_code_is_mandatory → не синкаются. description: null, barcode: null, item_code_is_mandatory: false, tru_code: null — подтверждённые Koala дефолты.
  6. SKU — primary lookup key. Формат: "{erp_product_id}[:{structural_option_id}][:+{free_option_id}]". Примеры:
    • Базовый товар без модификаторов: "abc-123"
    • Структурный вариант: "abc-123:size-30"
    • Addon к варианту: "abc-123:size-30:+cheese-chr"
  7. Дедуп при первичном онбординге. Если в ЛК PK уже есть товар с совпадающим SKU — мы делаем update (наш SKU уникален). Если совпадение по name (но другой SKU — ручное заведение до sync) — skip и WARN в pk_catalog_sync_runs.
  8. Нет кнопки «Очистить каталог PK». При отключении PK-аккаунта (suspend/delete) каталог в PK не удаляется.
  9. Уникальность mapping’а. (account_id, erp_product_id, erp_structural_option_id, erp_free_option_id) — UNIQUE. (account_id, pk_product_id) — UNIQUE (для обратного webhook’а).
  10. Soft-delete при удалении. Удаление товара/модификатора в ERP → mapping переходит в status=deleted, в PK отправляется DELETE /product/{id}.
  11. Работа только с активными аккаунтами. Sync не выполняется для suspended / deleted аккаунтов. При возобновлении — автоматический full re-sync.
  12. Throttle. Между operations sleep ~500 μs; каждые 50 DELETE — пауза 5 мс; между страницами list-запросов — 500 мс. Эмпирика из Koala-прецедента.

Ошибки и повторные попытки

  • Все исходящие вызовы в PK идут через pk_outbox — тот же механизм что для invoices.
  • Backoff: 10s / 30s / 2m / 10m / 1h / 6h / 24h. После 10 попыток → dead_letter + Slack-алерт.
  • Недоступность ims-api эндпоинта (404/timeout >3 попыток подряд) → email-алерт админу: «Catalog sync для ЮЛ [название] в состоянии failed».
  • При rate-limit ошибке от PK (429) — отдельный backoff и уменьшение batch size.

Ролевой доступ

ДействиеPermission
Просмотр статуса sync и журналаintegrations.read
Запуск manual re-syncintegrations.manage
Просмотр детализации ошибокintegrations.manage

Permissions те же что для управления PK-аккаунтами — новых не заводим.

UI в админке франшизы

В существующей вкладке «PayKeeper» карточки ЮЛ — новый блок «Каталог в PayKeeper»:

┌─ Каталог в PayKeeper ─────────────────────────────┐
│ Товаров синхронизировано: 142 / 142                │
│   (47 базовых × модификаторы)                      │
│ Последняя синхронизация: 2 мин назад               │
│ Статус: ✓ Успешно                                  │
│                                                     │
│ [Пересинхронизировать]   [Журнал прогонов]        │
└─────────────────────────────────────────────────────┘

Счётчик показывает общее количество PK-товаров (базовые + развёрнутые варианты + addon’ы). В скобках — справочно количество исходных товаров ERP для понимания масштабов.

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

  • Видна при permission integrations.manage.
  • При клике — disable кнопки, показать прогресс-индикатор.
  • По завершении — toast с результатом и обновлением счётчиков.

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

  • Видна при integrations.read.
  • Модалка со списком последних Sync Runs (последние 50): дата, trigger, счётчики, статус. Клик на строку — детали: список ошибок с кодами и сообщениями.

Подробная фронт-спека — в Интеграция PayKeeper (фронт) (будет обновлено при декомпозиции BR 3.4).

Метрики и алерты

  • paykeeper_catalog_sync_last_success_ts{account_id} — Prometheus gauge. Алерт: если > 25 часов → PagerDuty.
  • paykeeper_catalog_sync_errors_total{account_id, op_type} — counter.
  • paykeeper_catalog_products_diverged{account_id} — count товаров с устаревшим hash (расхождение между ERP и paykeeper_products). Алерт: если > 5% от общего → email админу франшизы.
  • Ежесуточный email-алерт владельцу франшизы/партнёра если последний Sync Run этого ЮЛ status=failed.

Интеграция со смежными flow

  • BR 3.3 payment flow не затрагивается: cart по-прежнему передаётся прямо в service_name invoice’а, pk_product_id там не используется (PK фискализирует то что прислали). Catalog sync — отдельный параллельный канал.
  • Отключение / приостановка PK-аккаунта (раздел «Статусы интеграции» выше) — при переходе active → suspended sync останавливается, при обратном переходе — автоматический full re-sync.
  • Reverse webhook (PK → ERP при правке цены в ЛК PK) — в планах PK (PK-summary-answers §6). Когда доступно — отдельный Kafka-топик catalog.pk_upstream_changed, Catalog Service решает стратегию (перезапись / алерт / игнор).

Зависимости от PayKeeper (актуальные)

Большинство исходных вопросов к PK техподдержке закрыто боевым прецедентом Koala_TG_app + koala_backend (Laravel-сервис + Spring Boot admin), где уже реализован sync каталога iiko → PK. Подтверждено:

  • База ims-api: https://ims-api.paykeeper.ru/api/v1 (единый глобальный хост для всех мерчантов, не per-tsp).
  • Auth: отдельный JWT через GET /info/settings/service-token?service=ims-api.paykeeper.ru&user={login} на {tsp}.server.paykeeper.ru (Basic login+password ЛК PK → Bearer JWT).
  • Endpoints: POST /products (обёртка {"product": {...}}) • GET /product/{id}GET /products?limit&start_from (pagination) • PATCH /product/{id}DELETE /product/{id}GET /products/search/?search_string=X.
  • Payload fields: name, price, item_type: "goods", tax: "vat20"|"vat10"|"vat0"|"none", sku, description, barcode, item_code_is_mandatory, tru_code.
  • Категорий в API нет — flat-список товаров. Решение: префикс в имени (см. Правило sync’а).
  • Модификаторов в API нет — каждая опция заливается как отдельный товар (см. Правило развёртывания).
  • pk_product_id возвращается синхронно в response POST /products.
  • Rate limits явно не документированы, но эмпирически sleep 500 мс между страницами, ~500 μs между записями, блок 5 мс каждые 50 DELETE — работает стабильно в Koala.
  • Удаление — hard через DELETE /product/{id}.

Остались открытые пункты:

  • Обратный webhook PK → ERP при правке цены/имени в ЛК PK — в планах PK, ждём реализации. В mapping’е уже готовим поля для будущего reverse lookup.
  • Картинки товаров — ims-api не поддерживает image_url. Не синкаем.
  • Тестовый аккаунт — используем существующий ЛК Koala koala-test.server.paykeeper.ru (активен, ims-api включён).

Импорт сотрудников из PayKeeper

Источник требований

Цель

Кассиры в нашем ERP (employees) и в ЛК PK (user/operator) — две разные сущности с непересекающимся набором обязательных полей. Реальный кейс: клиент использовал кассу 3-в-1 PayKeeper до подключения нашего ERP, в его ЛК PK уже заведено N кассиров. При подключении ERP не хочется перебивать руками.

Решение: по требованию владельца (по нажатию кнопки) забираем список из ЛК PK через GET /info/organization/users/, показываем диф с существующими employees, открываем wizard дозаполнения недостающих обязательных полей, импортируем пакетом.

Принцип

При подключении ERP к PK ничего автоматически не импортируется. Импорт — только по явному действию владельца. Никакого cron, никакого webhook на старте.

Push-сценарий «новый сотрудник у нас → создаём в ЛК PK через POST /change/user/add/» — отдельная BR 3.6 (Phase P3), не входит в скоуп BR 3.5.

Сущности

PK User mapping

Привязка нашего employee к пользователю ЛК PK. Одна запись — одна связь.

ПолеОбязательностьОписание
idОбязательно (auto)UUID
account_idОбязательноFK → PK-аккаунт
pk_user_idОбязательноID пользователя в ЛК PK (поле id из response API)
pk_loginОбязательноЛогин пользователя в ЛК PK (поле login — для матча с user_login в чеках)
employee_idОбязательноFK → User Service employees
created_at / updated_atОбязательно (auto)

UNIQUE (account_id, pk_user_id) — один pk_user в одном ЛК = одна запись mapping. UNIQUE (account_id, employee_id) — один employee связан только с одним pk_user в данном ЛК (employee может быть связан с разными pk_user в разных ЛК PK — для франшиз с несколькими ЮЛ).

ON DELETE CASCADE по обоим FK — при удалении employee или PK-аккаунта mapping удаляется автоматически.

User Import Run

История прогонов импорта (для аудита + отображение «Журнал импортов» в админке).

ПолеОбязательностьОписание
idОбязательно (auto)UUID
account_idОбязательноFK → PK-аккаунт (какой ЛК импортировали)
triggerОбязательноmanual (других нет в P0)
initiated_by_user_idОбязательноFK → employees (кто запустил импорт)
started_at, finished_atОбязательно (auto)
users_createdОбязательноСколько новых employees создано
users_linkedОбязательноСколько существующих employees связано с pk_user (без изменения данных)
users_updatedОбязательноСколько существующих employees обновлены полями из PK
users_skippedОбязательноСколько пропущено по решению владельца
users_erroredОбязательноСколько с ошибками
statusОбязательноrunning / success / partial / failed
errors_jsonНеобязательноДетали ошибок: [{pk_user_id, pk_login, message}]

Маппинг полей PK → ERP employee

PK даёт минимум, остальное дозаполняет владелец в wizard’е.

Поле PKТипКуда у нас
idstringpaykeeper_users.pk_user_id
loginstringpaykeeper_users.pk_login (матч с user_login в receipt webhook’ах)
emailstring | null | ""employees.email (если null/пусто — wizard требует ввод)
fiostring | null | ""employees.first_name + last_name (split по пробелу: первое слово → last_name, второе → first_name, остальное игнорируется; владелец корректирует в wizard’е)
admin”true”/“false”Отображается в wizard’е как информация. Не маппится в наши permissions/роли (наша permission-модель глубже плоского флага PK)
refundstring числоНе используется
invoices_onlyboolНе используется

Поля, которые wizard требует дозаполнить владельцу

Обязательные у нас, отсутствующие в PK:

  • first_name, last_name — split fio (если fio пустое — обязательный ручной ввод)
  • password — две опции: «Сгенерировать + reset-link на email» / «Ввести вручную»
  • franchise_id — автоматически из JWT текущего пользователя (не показываем в wizard’е)

Опциональные:

  • phone, pin_hash, is_courier — wizard показывает поля, владелец заполняет если нужно
  • roles[] + role_store_mappings — multi-select из активных permissions-ролей + per-role выбор ТТ (по правилам Создание сотрудника)

Триггер

Только manual — по нажатию кнопки в админке. В P0 нет:

  • Cron-импорт
  • Webhook от PK на изменение users (PK не обещал)
  • Авто-импорт при подключении ERP к PK

При повторных нажатиях кнопки уже импортированные (есть mapping в paykeeper_users) показываются в wizard’е как 🔗 уже связаны — не дублируются.

Flow импорта

sequenceDiagram
  participant W as Admin Web
  participant B as Admin BFF
  participant A as Paykeeper Adapter
  participant PK as PayKeeper ЛК
  participant US as User Service

  W->>B: POST /paykeeper/employees/preview {account_id}
  B->>A: вызов adapter
  A->>A: проверка статуса аккаунта (active?)
  A->>PK: GET /info/organization/users/ (Basic auth: pk_login/pk_password)
  PK-->>A: список users [{id, login, email, fio, admin, ...}]
  A->>A: для каждого user — lookup в paykeeper_users
  A->>US: GET /internal/employees?email=... (для match by email)
  A->>A: формирует diff: [{pk_user, status: 'new'|'matched_email'|'already_linked'}]
  A-->>B: ответ с diff
  B-->>W: показ wizard'а

  W->>B: POST /paykeeper/employees/import {account_id, decisions[]}
  B->>A: вызов
  A->>A: создание paykeeper_user_imports record (status=running)
  loop для каждого decision
    alt decision = create_new
      A->>US: POST /api/v1/employees {first_name, last_name, email, password, ...}
      US-->>A: {employee_id}
      A->>A: INSERT paykeeper_users mapping
    else decision = link_existing
      A->>A: INSERT paykeeper_users mapping (без вызова User Service)
    else decision = update_existing
      A->>US: PATCH /api/v1/employees/{id} (только пустые поля)
      A->>A: INSERT paykeeper_users mapping
    else decision = skip
      A->>A: записать в errors_json {pk_user_id, message: 'skipped by owner'}
    end
  end
  A->>A: UPDATE paykeeper_user_imports (status=success/partial/failed)
  A-->>B: report {created, linked, updated, skipped, errored}
  B-->>W: показ результата

Дубли email — 4 опции владельца

При совпадении pk_user.email с существующим employees.email (в рамках franchise_id) wizard для каждого conflict-row предлагает 4 варианта:

ОпцияЭффект
Связать с существующимINSERT в paykeeper_users(pk_user_id, employee_id). Existing employee не меняется.
Создать нового с другим emailWizard просит ввести альтернативный email → POST /employees с новыми данными
ПропуститьНичего не делаем; в errors_json пишется {action: 'skipped', reason: 'duplicate email, owner choice'}
Обновить существующегоPATCH /employees/{id}: записываем login в mapping, fiofirst/last_name только если у нас эти поля пустые (не перезаписываем непустое без подтверждения)

Бизнес-правила

  1. PK API доступен только для active PK-аккаунтов. Для suspended или not_configured кнопка «Выгрузить из PK» disabled — toolt’ip объясняет почему.
  2. Auth для запроса к PK — те же pk_login/pk_password что для invoice/IMS API (из paykeeper_accounts). Basic auth, без отдельных credentials.
  3. Дедуп при повторном pull. Уже импортированные (есть запись в paykeeper_users) показываются в wizard’е с флагом 🔗 уже связаны — кнопки действий не активны для них. Не создаём дубли.
  4. employees.franchise_id проставляется автоматически из JWT владельца (как при обычном POST /employees).
  5. Email уникален в рамках franchise_id — это правило из Сотрудники не нарушается. При попытке создать второго employee с тем же email → backend вернёт ошибку, wizard покажет владельцу.
  6. Системные пользователи PK (login: admin, login: user) показываются в wizard’е с пометкой «системный пользователь PK» — владелец сам решает импортировать или пропустить.
  7. admin: true в PK — не маппится автоматически в нашу системную роль «Администратор». Владелец сам выбирает permissions-роли в wizard’е (наша permission-модель не имеет плоского admin).
  8. fio пустое — wizard требует обязательного ввода имени и фамилии перед импортом.
  9. PK не отдаёт phone, pin — поля остаются в форме wizard’а пустыми, владелец заполняет если нужно.
  10. Каскадное удаление. При удалении employees (User Service) или paykeeper_accounts mapping в paykeeper_users удаляется автоматически (FK ON DELETE CASCADE).
  11. Не блокируем платёжный flow. Если pull-запрос к PK упал — никак не влияет на основные сценарии (invoice creation, webhook’и). Импорт изолирован.

Ролевая матрица

ДействиеВладелец франшизыВладелец партнёраМенеджер ТТКассир
Видеть кнопку «Выгрузить из PK»✅ (только свои ЛК)
Запустить preview (получить список из PK)✅ (свои ЛК)
Подтвердить импорт (создание employees)✅ (свои ЛК)
Видеть журнал импортов✅ (свои ЛК)

Permissions (переиспользуем существующие, не вводим новые):

  • integrations.read — видеть кнопку и журнал импортов
  • integrations.manage — запускать preview (вызов PK API)
  • employees.edit — подтверждать import (создание employees в нашем ERP)

Комбо integrations.manage + employees.edit

Запуск import требует обоих permissions: операция совмещает интеграцию с PK (вызов API) и создание сотрудников. У владельца франшизы и владельца партнёра в системной роли «Администратор» оба есть; у обычных сотрудников — обычно нет (что корректно). Логика комбо устойчива к будущему BR 3.6 (push), где permissions будут те же.

UI в админке

Кнопка «Выгрузить из PK» в разделе Сотрудники (/admin/employees) на панели действий рядом с «Добавить сотрудника». Видна только при integrations.read. Активна только при integrations.manage AND employees.edit AND есть хотя бы один active PK-аккаунт во scope пользователя.

Если у франшизы несколько ЛК PK — при клике сначала модалка выбора {select PK-аккаунт}. Если только один — сразу запускается preview.

После preview — wizard 3 шага:

  1. Шаг 1: список кандидатов — таблица из ЛК PK с автоматическим матчингом, per-row выбор действия
  2. Шаг 2: дозаполнение — для create_new и update_existing форма заполнения недостающих полей (first/last_name, password, phone, pin, roles+stores)
  3. Шаг 3: результат — отчёт {created, linked, updated, skipped, errored} с деталями

Кнопка «Журнал импортов» — модалка со списком последних 50 прогонов из paykeeper_user_imports: дата, ЛК PK, кто запустил, счётчики, статус. Клик на строку — детали ошибок.

Подробная фронт-спека → Импорт сотрудников из PayKeeper (фронт) (создаётся в Шаг 3 декомпозиции).

Метрики и алерты

  • paykeeper_user_import_runs_total{result, account_id} — counter прогонов по статусу success / partial / failed
  • paykeeper_user_import_users_total{action, account_id} — counter обработанных пользователей: created / linked / updated / skipped / errored
  • paykeeper_user_import_duration_seconds — histogram длительности прогона

Алертов на этом уровне не делаем — импорт инициирует владелец, ошибка показывается ему в админке. Email/PagerDuty не нужны.

Зависимости от PayKeeper

  • Endpoint GET /info/organization/users/ — подтверждён работающим (POC 2026-04-27)
  • Auth — Basic auth с pk_login/pk_password ЛК PK (те же креды что для invoice flow)
  • Поля в response: id, login, email, fio, admin, refund, invoices_only
  • Чего PK не отдаёт: phone, pin, дата создания, дата изменения user’а в ЛК PK
  • Пагинации нет — endpoint возвращает полный список одним запросом (на практике у одной ТТ ≤30 пользователей в ЛК)

Открытые вопросы — см. §10 BR 3.5.

Что НЕ входит

  • Push сотрудников ERP → ЛК PK — отдельная BR 3.6 (Phase P3)
  • Reverse-webhook PK → ERP на изменение user в ЛК — PK не обещал, ждём
  • Удаление user в PK при удалении employee у нас — отдельная BR
  • Cron-периодический pull — только manual в P0
  • Запись в журнал PK активности импорта — нет endpoint’а, не нужно

Reconciliation

Per-invoice poll

После paykeeper.invoice.created адаптер планирует проверку через 25 мин, интервал 5 мин. Поллит GET /info/invoice/byid/ до status=paid или TTL инвойса. Если informer не пришёл — догружает платёж через GET /info/payments/byid/ и публикует событие как будто от informer’а (с пометкой reconciled=true).

Ночной stuck-cron

Каждую ночь в 03:00 MSK per-аккаунт:

  1. GET /info/payments/bydate/?status[]=stuck — платежи без оповещения
  2. Для каждого → POST /change/payment/repeatcnt/ (просим PK повторить информер)
  3. Если через 10 мин informer не пришёл — догрузка через byid + публикация события

Недельная сверка

Каждый понедельник в 04:00 сверка сумм GET /info/systems/sums/ vs orders.total за неделю. Расхождение >0.5% → Slack-алерт.


Ролевая матрица

ДействиеВладелец франшизыВладелец партнёраМенеджер ТТКассир
Просмотр списка PK-аккаунтовВсе ЮЛТолько свои ЮЛ
Создать / редактировать PK-аккаунтЛюбое ЮЛТолько свои ЮЛ
Приостановить / возобновить PK-аккаунтЛюбое ЮЛТолько свои ЮЛ
Удалить PK-аккаунтЛюбое ЮЛ (если нет активных)Только свои (если нет активных)
Привязать ТТ к терминалуВсе ТТТолько свои ТТ
Просмотр статуса интеграции в карточке ТТ✅ (свои)✅ (своя ТТ, read-only)
Просмотр журнала платежей/возвратов✅ (свои)✅ (своя ТТ, read-only)
Инициировать возврат из админки✅ (по orders.refund)✅ (по orders.refund)✅ (по orders.refund в своей ТТ)
Проверить соединение с PK✅ (свои)

Permissions (переиспользуем существующие, не заводим новые):

  • integrations.read — просмотр PK-аккаунтов, привязок, журналов
  • integrations.manage — CRUD PK-аккаунтов и привязок терминалов
  • orders.refund — инициация возврата (уже существует в Заказах)

Список PK-аккаунтов

Колонки

  • Юридическое лицо
  • Хост ЛК ({tsp}.server.paykeeper.ru)
  • Номер договора PK (paykeeper_id)
  • Статус (active / suspended)
  • Количество привязанных ТТ
  • Дата подключения

Фильтры

  • По статусу (Активен / Приостановлен)
  • По юрлицу (только видимые по scope)

Поиск

  • По названию ЮЛ и хосту ЛК

Сортировка

  • По умолчанию по названию ЮЛ (А-Я)

Пагинация

  • 20 записей на страницу

Карточка PK-аккаунта

Вкладки

  1. Настройки — поля аккаунта (host, login, password, informer_seed, статус). Пароли — masked, возможность «Показать».
  2. Webhook URLs — 3 готовых URL’а для копирования в ЛК PK:
    • https://{host}/pk-webhooks/informer/{account_id} (успешная оплата)
    • https://{host}/pk-webhooks/refund/{account_id} (возврат)
    • https://{host}/pk-webhooks/receipt/{account_id} (финальный статус чека)
  3. Журнал событий — лог последних 100 webhook’ов (дата, тип, статус валидации, обработан ли, ID заказа)

Действия и подтверждения

Создание PK-аккаунта

Форма: pk_server_host, pk_login, pk_password, informer_seed (+ paykeeper_id опционально).

Кнопка «Проверить соединение» выполняет GET /info/settings/token/ через переданные креды → показывает зелёный (OK) / красный (ошибка + текст от PK).

Сохранение разрешено только при успешной проверке.

После создания — модалка с 3 webhook URL’ами и инструкцией: «Скопируйте эти URL’ы в ЛК PayKeeper → Настройки → Уведомления. В поле “Секретное слово” введите тот же informer_seed что указали здесь».

Приостановка

Модалка: «Будет приостановлена интеграция с PK для ЮЛ [название]. Новые платежи не будут приниматься, существующие заказы продолжат обрабатываться до закрытия».

Удаление

  • Если есть открытые инвойсы или незакрытые возвраты — показать список и блокировать удаление
  • Иначе — модалка «Вы уверены?» с повторным подтверждением (соответствует стилю удаления ЮЛ)

Проверить соединение

Отдельная кнопка в вкладке «Настройки» — повторная проверка креденшелов через GET /info/settings/token/.


Интеграция с другими модулями

Юридические лица

PK-аккаунт привязан 1:1 к ЮЛ. В карточке ЮЛ — подсекция «Интеграция PayKeeper» с кратким статусом и ссылкой на главную спеку. См. Юридические лица.

Торговые точки

Каждая ТТ может иметь привязку к терминалу PK. Подсекция «Привязка к терминалу PayKeeper» в карточке ТТ. См. Торговые точки.

Каталог

Товары должны содержать поля для 54-ФЗ: vat_rate, payment_subject, payment_type. Без них товар нельзя включить в инвойс PK. См. Каталог.

Изменения товаров, категорий и модификаторов автоматически синхронизируются в ЛК PayKeeper — см. секцию Catalog Sync выше. Это обеспечивает работу Кассы 3в1 без нашего POS и self-service оплату через ЛК PK.

Заказы

Возврат оплаченного заказа инициируется через PK. Флоу и кнопки описаны в Заказы.


Что НЕ входит в P0

  • Sync каталога ERP → PK ims-apiперенесён в скоуп и описан в секции Catalog Sync выше (BR 3.4). В P0 платёжного flow не задействуется — cart передаётся в invoice напрямую, независимо от синхронизации каталога.
  • Pull сотрудников ЛК PK → ERPперенесён в скоуп и описан в секции Импорт сотрудников из PayKeeper выше (BR 3.5).
  • Push сотрудников ERP → ЛК PK (создание employee у нас → автосоздание user в ЛК PK через POST /change/user/add/) — отдельная BR 3.6 (Phase P3).
  • Автоматический онбординг ЛК (PayKeeper Autoinstaller) — ручная регистрация у PK вне ERP. Phase P3.
  • Чек окончательного расчёта для delivery (POST /change/payment/post-sale-receipt/) — только обычные sale receipts в P0. Phase P1+.
  • SBP QR активация (POST /change/sbpqr/activate) — не нужна для bar-flow. Опц. для POS BFF позже.
  • 2-stage hold (pre-auth + capture) — только 1-stage в P0. Phase P1+.
  • Обратный webhook каталога (PK → ERP при правке в ЛК) — в планах PK, нам не контролируемо.
  • Привязки карт (безакцепт через bank_id) — не нужно для MVP.
  • API выплат (OCT) — не нужно.
  • Admin-UI для журнала фискальных чеков — в P0 только базовый журнал webhook’ов. Расширенный — Phase P1.

Ссылки