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 | Обязательно, UNIQUE | FK → 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 | Обязательно, UNIQUE | FK → 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).
| Поле | Описание |
|---|---|
| id | UUID |
| order_id | FK на наш заказ |
| account_id | FK на PK-аккаунт |
| pk_invoice_id | ID счёта у PK |
| pk_invoice_url | Ссылка для оплаты |
| pk_status | created / sent / paid / expired |
| created_at, paid_at |
Платёж
Запись informer success от PK.
| Поле | Описание |
|---|---|
| id | UUID |
| order_id | FK (resolved из упакованного orderid) |
| pk_payment_id | ID платежа у PK |
| pk_unique_id | Уникальный ID транзакции у банка |
| pay_amount | Сумма |
| payment_system_id | ID платёжной системы PK |
| status | paid (аналог у PK — success) |
| bank_id | ID привязки карты (если была) |
| pending_datetime, obtain_datetime, success_datetime | |
| raw_informer_json | Полный payload для аудита |
| received_at |
Фискальный чек
Запись о чеке 54-ФЗ (догружается после оплаты).
| Поле | Описание |
|---|---|
| id | UUID |
| payment_id | FK на наш платёж |
| pk_receipt_id | ID чека у PK |
| type | sale / refund / expense / expense-refund |
| is_post_sale | Чек окончательного расчёта (для delivery) |
| status | created / request_sent / success / timeout / failed / rejected |
| fpd, fnd, fn | Фискальный признак, номер ФД, ФН |
| rnkkt | Регистрационный номер ККТ |
| shift_number, receipt_number | Номер смены и чека в смене |
| fop_receipt_key, fop_url | Ссылки на чек и QR-контент |
| ts | Время формирования |
| received_at |
Возврат
Запись о возврате платежа.
| Поле | Описание |
|---|---|
| id | UUID |
| order_id | FK |
| payment_id | FK |
| pk_refund_id | ID возврата у PK |
| amount | Сумма возврата |
| status | started / done / failed |
| initiated_by | admin / pos — источник инициации |
| reason | Причина |
| datetime | Дата выполнения |
Статусы интеграции
stateDiagram-v2 [*] --> not_configured: ЮЛ создано, PK не подключен not_configured --> active: Креды введены + проверены active --> suspended: Приостановлено админом suspended --> active: Возобновлено active --> [*]: Удалено (если нет активных заказов) suspended --> [*]: Удалено
Поведение по статусу:
| Статус | Можно принимать оплату? | Видно в админке |
|---|---|---|
not_configured | Нет | Скрыто |
active | Да | Полный UI |
suspended | Нет | Read-only, кнопка «Возобновить» |
Бизнес-правила
- Один ЛК PK = одно юрлицо.
paykeeper_accounts.legal_entity_id— UNIQUE. Нельзя использовать один ЛК PK для разных юрлиц сети. - Одна ТТ = один терминал.
paykeeper_terminals.store_idиpaykeeper_terminals.pk_terminal_id— UNIQUE. - ТТ без привязки не может принимать оплату через PK. При попытке создать invoice →
422 INTEGRATION_NOT_CONFIGURED. - Шифрование секретов.
pk_password,informer_seedхранятся AES-GCM. Ключ — в envPAYKEEPER_SECRETS_KEY(32 байта, из K8s/docker secret). - Проверка подписи обязательна. Webhook с несовпадающим HMAC/MD5 → 400, заказ не закрывается.
- Dedup webhook’ов. По
(account_id, pk_payment_id)для informer/refund и(account_id, pk_receipt_id)для receipt callback. Повторы идемпотентны. - Удаление PK-аккаунта запрещено если есть открытые инвойсы (
pk_status IN (created, sent)) или незакрытые возвраты (status=started). - Приостановка ЮЛ каскадирует на PK-интеграцию. При
legal_entities.status=suspended→paykeeper_accounts.status=suspendedавтоматически.
Платёжный flow
Вариант 1. Оплата через терминал PK в ТТ (bar-flow)
- Кассир в POS собирает заказ → нажимает «К оплате»
- Order Service публикует
order.payment_requested(заказ готов к оплате) - Адаптер получает событие → вызывает
POST /change/invoice/preview/в PK → получаетinvoice_id,invoice_url - Адаптер публикует
paykeeper.invoice.created→ Order Service сохраняетpk_invoice_id - На терминале PK кассир видит заказ в очереди → нажимает «Принять оплату»
- Клиент платит (карта/QR/наличные)
- PK шлёт informer на наш webhook → адаптер валидирует MD5 → публикует
paykeeper.payment.received - Order Service закрывает заказ (
status=paid,paid_at) - Через ~30 сек адаптер догружает фискальные атрибуты (ФН/ФД/ФП/смена) → публикует
paykeeper.receipt.fiscalized - Order Service сохраняет фискальные данные для отчётов и налоговой
Вариант 2. Ссылка на оплату клиенту (online/delivery-flow)
Те же шаги, но после п.4 ссылка invoice_url отправляется клиенту (Telegram-бот / SMS / email). Клиент платит → дальше шаги 7-10.
Flow возврата
- Сотрудник с permission
orders.refundнажимает «Вернуть» в карточке закрытого заказа - Выбирает полный или частичный возврат, указывает причину
- Order Service публикует
order.refund_requested - Адаптер вызывает
POST /change/payment/reverse/в PK (сamount,partial,refund_cart) - PK принимает запрос (async), отвечает
{"result":"success"} - Через N минут PK шлёт refund webhook → адаптер валидирует MD5 → публикует
paykeeper.payment.refunded - Order Service помечает возврат
done, обновляетrefund_records
Ошибки:
- Возврат в PK отклонён →
paykeeper.refund.failedсmsg→ админка показывает ошибку - Webhook не пришёл за 10 мин → reconciliation cron вручную проверяет статус через
GET /info/refunds/bypaymentid/
Catalog Sync
Источник требований
Цель
Держать каталог в ЛК PayKeeper в синхронизированном состоянии с каталогом ERP. Нужно для двух сценариев:
- Offline / без нашего POS. Владелец хочет, чтобы Касса 3в1 от PK работала самостоятельно: кассир выбирает товары на терминале из ЛК PK, формирует счёт, печатает чек.
- Self-service через ЛК PK / embed-форма. Клиент (интернет-заказ, выставленная ссылка) выбирает товары сам через интерфейс PK.
В обычном платёжном flow (BR 3.3) каталог PK не используется — корзина передаётся в service_name.cart прямо в момент создания invoice. Catalog sync — отдельный независимый канал для сценариев где наш POS в цепочке отсутствует.
Что синхронизируется в ЛК PK
PayKeeper ims-api предоставляет только flat-список товаров — в нём нет понятий «категория», «группа модификаторов», «опция». Поэтому:
- Товары ERP → отдельные записи в PK.
- Категория товара → склеивается в
nameчерез префикс:"Кофе / Капучино". - Модификаторы ERP → разворачиваются в отдельные товары в PK (подробности ниже).
- 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-товары (включая развёрнутые из модификаторов).
| Поле | Описание |
|---|---|
| id | UUID |
| account_id | FK → PK-аккаунт |
| trigger | cron / manual / webhook_missed |
| started_at, finished_at | |
| products_upserted, products_deleted | Счётчики PK-товаров (базовые + развёрнутые варианты + addon’ы) |
| errors_count | |
| status | running / success / partial / failed |
| errors_json | Детали ошибок: [{entity_type, erp_id, erp_variant, message}] |
Триггеры синхронизации
- Event-driven delta — основной путь. Catalog Service публикует Kafka-события при изменении товара (
catalog.product.upserted/.deleted) или его модификаторов (catalog.modifier_group.upserted/.deleted→ влияет на все товары где эта группа привязана). Adapter consume’ит, ре-генерирует полный список виртуальных продуктов для этого корневого товара (по правилу развёртывания), диффит против текущихpaykeeper_productsи кладёт нужные upsert/delete в outbox. - Cron ночной re-sync — ежедневно в 03:00 MSK. Adapter идёт в Catalog Service за полным снепшотом (
GET /internal/catalog/full-snapshot?franchise_id=X), разворачивает каждый товар по правилу, считает hash’и, находит расхождения, догружает. Safety-net на случай пропущенных webhook’ов. - 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:
- Lookup
paykeeper_productsпоpk_product_id→ получает(erp_product_id, erp_structural_option_id, erp_free_option_id, variant_kind). - Публикует в Kafka
catalog.pk_upstream_changedс нормализованной нагрузкой. - Catalog Service consume’ит → либо алерт админу, либо автообновление цены (business-logic TBD в будущем BR).
Уже сейчас мы храним обратный lookup-ключ (pk_product_id UNIQUE per account) + достаточно информации чтобы понять «что именно за вариант» ERP-товара PK имеет в виду. Конкретная обработка webhook’а — вне скоупа BR 3.4.
Бизнес-правила
- Тиражирование во все PK-аккаунты франшизы. Каталог ERP — на уровне франшизы, каталог PK — per-ЮЛ. Если у франшизы несколько юрлиц с подключённым PK, одно и то же изменение товара растиражируется в N ЛК PK.
- Единая цена на товар. v1 использует базовую цену
products.price+ доплаты модификаторов. Прейскуранты per-store (разные цены на разных ТТ внутри одного ЮЛ) в v1 не учитываются. - Категории → в имя префиксом (
"Кофе / Холодное / Айс Латте"). Отдельной сущности категорий в PK нет. - Модификаторы → в отдельные товары (см. Правило развёртывания выше). Опции получают префикс имени корневого товара:
"Пицца Маргарита 30 см доп. Сыр Чеддер". - Картинки, tru_code, barcode, item_code_is_mandatory → не синкаются.
description: null,barcode: null,item_code_is_mandatory: false,tru_code: null— подтверждённые Koala дефолты. - 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"
- Базовый товар без модификаторов:
- Дедуп при первичном онбординге. Если в ЛК PK уже есть товар с совпадающим SKU — мы делаем update (наш SKU уникален). Если совпадение по name (но другой SKU — ручное заведение до sync) — skip и WARN в
pk_catalog_sync_runs. - Нет кнопки «Очистить каталог PK». При отключении PK-аккаунта (suspend/delete) каталог в PK не удаляется.
- Уникальность mapping’а.
(account_id, erp_product_id, erp_structural_option_id, erp_free_option_id)— UNIQUE.(account_id, pk_product_id)— UNIQUE (для обратного webhook’а). - Soft-delete при удалении. Удаление товара/модификатора в ERP → mapping переходит в
status=deleted, в PK отправляетсяDELETE /product/{id}. - Работа только с активными аккаунтами. Sync не выполняется для
suspended/deletedаккаунтов. При возобновлении — автоматический full re-sync. - 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-sync | integrations.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_nameinvoice’а,pk_product_idтам не используется (PK фискализирует то что прислали). Catalog sync — отдельный параллельный канал. - Отключение / приостановка PK-аккаунта (раздел «Статусы интеграции» выше) — при переходе
active → suspendedsync останавливается, при обратном переходе — автоматический 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 | Тип | Куда у нас |
|---|---|---|
id | string | paykeeper_users.pk_user_id |
login | string | paykeeper_users.pk_login (матч с user_login в receipt webhook’ах) |
email | string | null | "" | employees.email (если null/пусто — wizard требует ввод) |
fio | string | null | "" | employees.first_name + last_name (split по пробелу: первое слово → last_name, второе → first_name, остальное игнорируется; владелец корректирует в wizard’е) |
admin | ”true”/“false” | Отображается в wizard’е как информация. Не маппится в наши permissions/роли (наша permission-модель глубже плоского флага PK) |
refund | string число | Не используется |
invoices_only | bool | Не используется |
Поля, которые wizard требует дозаполнить владельцу
Обязательные у нас, отсутствующие в PK:
first_name,last_name— splitfio(если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 не меняется. |
| Создать нового с другим email | Wizard просит ввести альтернативный email → POST /employees с новыми данными |
| Пропустить | Ничего не делаем; в errors_json пишется {action: 'skipped', reason: 'duplicate email, owner choice'} |
| Обновить существующего | PATCH /employees/{id}: записываем login в mapping, fio → first/last_name только если у нас эти поля пустые (не перезаписываем непустое без подтверждения) |
Бизнес-правила
- PK API доступен только для
activePK-аккаунтов. Дляsuspendedилиnot_configuredкнопка «Выгрузить из PK» disabled — toolt’ip объясняет почему. - Auth для запроса к PK — те же
pk_login/pk_passwordчто для invoice/IMS API (изpaykeeper_accounts). Basic auth, без отдельных credentials. - Дедуп при повторном pull. Уже импортированные (есть запись в
paykeeper_users) показываются в wizard’е с флагом🔗 уже связаны— кнопки действий не активны для них. Не создаём дубли. employees.franchise_idпроставляется автоматически из JWT владельца (как при обычном POST /employees).- Email уникален в рамках
franchise_id— это правило из Сотрудники не нарушается. При попытке создать второго employee с тем же email → backend вернёт ошибку, wizard покажет владельцу. - Системные пользователи PK (
login: admin,login: user) показываются в wizard’е с пометкой «системный пользователь PK» — владелец сам решает импортировать или пропустить. admin: trueв PK — не маппится автоматически в нашу системную роль «Администратор». Владелец сам выбирает permissions-роли в wizard’е (наша permission-модель не имеет плоскогоadmin).fioпустое — wizard требует обязательного ввода имени и фамилии перед импортом.- PK не отдаёт
phone,pin— поля остаются в форме wizard’а пустыми, владелец заполняет если нужно. - Каскадное удаление. При удалении
employees(User Service) илиpaykeeper_accountsmapping вpaykeeper_usersудаляется автоматически (FK ON DELETE CASCADE). - Не блокируем платёжный 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: список кандидатов — таблица из ЛК PK с автоматическим матчингом, per-row выбор действия
- Шаг 2: дозаполнение — для
create_newиupdate_existingформа заполнения недостающих полей (first/last_name, password, phone, pin, roles+stores) - Шаг 3: результат — отчёт
{created, linked, updated, skipped, errored}с деталями
Кнопка «Журнал импортов» — модалка со списком последних 50 прогонов из paykeeper_user_imports: дата, ЛК PK, кто запустил, счётчики, статус. Клик на строку — детали ошибок.
Подробная фронт-спека → Импорт сотрудников из PayKeeper (фронт) (создаётся в Шаг 3 декомпозиции).
Метрики и алерты
paykeeper_user_import_runs_total{result, account_id}— counter прогонов по статусуsuccess/partial/failedpaykeeper_user_import_users_total{action, account_id}— counter обработанных пользователей:created/linked/updated/skipped/erroredpaykeeper_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-аккаунт:
GET /info/payments/bydate/?status[]=stuck— платежи без оповещения- Для каждого →
POST /change/payment/repeatcnt/(просим PK повторить информер) - Если через 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-аккаунта
Вкладки
- Настройки — поля аккаунта (host, login, password, informer_seed, статус). Пароли — masked, возможность «Показать».
- 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}(финальный статус чека)
- Журнал событий — лог последних 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.