Research — PayKeeper bridge/adapter architecture

Сжатые факты об устройстве моста ERP ↔ PayKeeper. Исходники: _reference/paykeeper/*.md (protocol JSON API), _assets/paykeeper/gap-analysis.md, _assets/paykeeper/*.yaml (OpenAPI — ims-api, lk-paykeeper, partner-api, autoinstaller, paykeeper-app-mpos, support-api, api-vyplat, applepay), _reference/paykeeper/PK-summary-answers.md (ответы техподдержки PK 2026-04-23), github.com/nearbyKoala/Koala_TG_app (Laravel-референс-интеграция с боевыми формулами подписей и полями informer’а — изучена 2026-04-23).


1. Актёры и периметр

ERP (наша система): Catalog, Store, Order, User, Warehouse, Customer, Aggregator сервисы + Admin/POS/Customer BFF. Истина для: каталог, прейскурант, остатки, клиенты, роли, заказы до оплаты.

PayKeeper (партнёр): mPOS-терминал, ФН/ОФД/54-ФЗ, эквайринг, личный кабинет (ЛК) для мерчанта, ИМС-каталог PK. Истина для: платежи, фискальные документы, смены ФН.

Мост (новый сервис, erp-paykeeper-adapter): anti-corruption layer + outbox + webhook-приёмник + reconciliation-кроны. НЕ содержит бизнес-логику ERP.

Внешние зависимости за периметром моста: ОФД (через PK), НСПК/банк-эквайер (через PK), «Честный знак» (через PK), Applepay/MirPay/YandexPay (через PK).


2. Поверхности интеграции

PK выставляет 10 публичных групп эндпоинтов (JSON API) + отдельные OpenAPI (ims-api каталог, autoinstaller онбординг, partner-api, paykeeper-app-mpos deep links, support-api).

2.1 Outbound (ERP → PK), синхронные

НаправлениеEndpoint PKКогда вызываемИсточник
Инициация оплатыPOST /change/invoice/preview/ + POST /change/invoice/send/Order Service: заказ готов к оплате§3.4/3.5
Прямая ссылка оплаты / QRPOST /change/sbpqr/activate / activatelnkPOS BFF: по кнопке «Оплата QR»§10.1/10.2
Капча pre-authPOST /change/payment/capture/Order Service: завершение отложенного списания§2.12
ВозвратPOST /change/payment/reverse/Order Service: возврат инициирован§2.8
Чек окончательного расчётаPOST /change/payment/post-sale-receipt/Aggregator Service: факт доставки заказа§2.13
Сброс счётчика оповещенийPOST /change/payment/repeatcnt/Reconciliation cron: найден stuck§2.9
Запрос токенаGET /info/settings/token/До любого POST, TTL = 24ч§6.1
Привязка карты / безакцептPOST /change/binding/execute/Опц. подписочные модели, не MVP§9.6
Добавление email-получателя отчётовPOST /change/organization/addreportemail/Онбординг§5.3
Управление пользователями LKPOST /change/user/add/update/delete/Синхронизация кассиров User Service ↔ ЛК PK§5.6-5.8
Установка informer URL / secretPOST /change/organization/setting/ (informer_url, informer_seed)Автоонбординг§5.1
Каталог — товарыPOST/PUT/DELETE /ims-api/productsCatalog Service изменил товарims-api.yaml
Каталог — тегиPOST/PUT/DELETE /ims-api/tagsCatalog Service изменил категориюims-api.yaml
Автоматическая установка ЛКPOST /autoinstaller/api/requestsРегистрация новой ТТ-франчайзиautoinstaller.yaml

2.2 Outbound (ERP → PK), polling / batch

Что тянемEndpoint PKПериодичностьПричина
Stuck-платежи (по которым webhook не дошёл)GET /info/payments/bydate/?status[]=stuck1 раз в сутки + по требованию§2.1 — рекомендация PK: основной fallback reconciliation
Счётчики для контрольной сверкиGET /info/payments/bydatecount/При подозрении на расхождение§2.2
Суммы по платёжным системамGET /info/systems/sums/Ежедневный отчёт§1.2
Фискальные атрибуты чекаGET /info/receipts/byid/ или /bypaymentid/После получения payment.success webhook§8.3/8.4
Доп. параметры платежа (RRN, APPROVAL_CODE, CARD_NUMBER, 3DSECURE)GET /info/options/byid/?id=Если полей нет в webhook§2.4
Список возвратов по платежуGET /info/refunds/bypaymentid/После инициации возврата → проверка статуса§2.7
Отчётность xlsxGET /export/payments/По требованию бухгалтерии§7.1

2.3 Inbound (PK → ERP)

СобытиеСпособВключениеПодпись
Успешная оплатаPOST на informer_url (из §4.3)Штатно, включеноMD5 по формуле (см. §6.4) — legacy PK informer, подтверждено Koala
Завершённый возвратPOST на отдельный URL (Koala: /payment/refund-webhook)Включается техподдержкой PK per-ЛКMD5 та же формула, что и informer success
Чек дошёл до финального статусаPOST callback§8.13HMAC-SHA256 по secret_word, payload = все поля из /info/receipts/byid/ + sign
Изменения каталога в ЛКнет на 2026-04-23В планах PK (ответ на вопрос #6)TBD
Смена ФН истекаетнетPK отказал (ответ #5: «не требуется — касса сама закроет-откроет смену»)

Важно: informer success и refund webhook — разные URL, одинаковая схема подписи (MD5). Receipt callback §8.13 — другая схема (HMAC-SHA256). Три разных приёмника.

2.3.1 Поля informer success / refund webhook (полный список)

Боевой список из Koala_TG_app/Services/PayKeeper/Webhook/Webhook.php (то что PK реально присылает):

ПолеТипОбяз.Назначение
idstring/intДаID платежа в PK
sumfloatДаСумма
clientidstringДаПередано в invoice (у нас — ФИО клиента или user_id)
orderidstringДаНаш bridge-ID (см. §7.2)
keystring(32)ДаMD5-подпись, ^[a-f0-9]{32}$
service_namestringНетJSON с cart + callback или произвольное
client_emailstringНетEmail плательщика
client_phonestringНетТелефон плательщика
ps_idintНетID платёжной системы
batch_datestringНетФлаг 2-стадийной оплаты (есть → hold активен)
fop_receipt_keystringНетКлюч чека 54-ФЗ (hasReceipt)
bank_idstringНетID привязки карты (hasCardBinding)
bank_payer_idstringНетID плательщика в банке
card_numberstringНетМаскированный номер карты (4000 11xx xxxx 1111)
card_holderstringНетИмя держателя
card_expirystringНетСрок действия
bank_operation_datetimestringНетТочное время операции в банке

Схема paykeeper://mobile.app/... — используется POS BFF и клиентским приложением для инициации оплаты на мобильном терминале кассира. Спека: paykeeper-app-mpos.yaml. 7 операций: invoice, refund, payout, check_state, check_shift, open_shift, close_shift — но последние 3 PK рекомендует не использовать (§5 ответа: касса сама управляет сменой).


3. Архитектурная форма моста

3.1 Выделенный сервис erp-paykeeper-adapter (уже в скоупе — memory project_erp_scope)

  • Tech: Java 21 + Spring Boot 3.x + PostgreSQL + Kafka. Паритет стека с другими бэкендами.
  • Порт: свободный :3015.
  • БД: paykeeper_adapter_db. Почему отдельная — изоляция credentials PK, ACL по принципу database-per-service.
  • Kafka — транспорт входящих событий PK к остальным сервисам ERP.

3.2 Почему не embed в существующий сервис

  • Aggregator Service — уже есть WebhookDispatcher, но он исходящий (ERP → сторонние POS типа KOALa). Paykeeper — входящий трафик + исходящие команды. Разные каналы, разная ответственность.
  • Order Service — не должен знать про /info/settings/token/ и HMAC-схемы PK (ACL принцип).
  • Отдельный сервис = отдельные права на секреты (PK login/password, informer_seed, service-token) в envs + изоляция deployment’а под перезапуск без касания Order/Catalog.

3.3 Внутренние модули адаптера

МодульРоль
pk-clientHTTP-клиенты к JSON API (10 групп) + ims-api + autoinstaller + partner-api. Basic auth, token refresh, retries, rate limit
webhook-receiverSpring MVC контроллеры на публичные endpoints, HMAC-валидация, dedup по payment_id/receipt_id, запись в webhook_log
outbox-dispatcherWorker, забирает из pk_outbox → вызов pk-client → публикация результата в Kafka или retry
event-publisherKafka producer для исходящих событий моста
event-consumerKafka consumer для order.refund_requested, catalog.product.updated, user.employee.pos_access_changed → outbox
reconciliation-schedulerCron: stuck-платежи, ежедневная сводка, фискальные атрибуты
mapping-registryТаблицы mapping’а: store_id ↔ pk_terminal_id, employee_id ↔ pk_user_id, product_id ↔ pk_product_id
credentials-vaultЧтение зашифрованных учёток PK из БД (AES-GCM, ключ из env)

4. Модель данных адаптера

paykeeper_accounts       — per-франчайзи/per-legal_entity
  id, franchise_id, legal_entity_id, pk_server_host,
  pk_login, pk_password_enc, informer_seed_enc,
  paykeeper_id, status, onboarded_at, last_token_at

paykeeper_terminals      — per-ТТ, бывает >1 терминала на точку
  id, account_id, store_id, pk_terminal_id, pk_mpos_merchant_id,
  label, status

paykeeper_user_mapping
  id, account_id, employee_id, pk_user_id, pk_login,
  synced_at, permissions_hash

paykeeper_product_mapping
  id, account_id, product_id, pk_product_id, pk_tag_id,
  synced_at, hash

paykeeper_invoices
  id, order_id, account_id, pk_invoice_id, pk_invoice_url,
  pk_status (created|sent|paid|expired), created_at, paid_at

paykeeper_payments       — приёмник informer success
  id, order_id, pk_payment_id, pk_unique_id, pay_amount,
  payment_system_id, status, bank_id (для привязки),
  pending_datetime, obtain_datetime, success_datetime,
  raw_informer_json, received_at

paykeeper_receipts
  id, payment_id (FK нашего pk_payment), pk_receipt_id,
  type (sale|refund|expense|expense-refund), is_post_sale,
  is_correction, status, fpd, fnd, fn, rnkkt, shift_number,
  receipt_number, fop_receipt_key, fop_url, ts,
  contact, cart_json, error_json, received_at

paykeeper_refunds
  id, order_id, payment_id, pk_refund_id, amount,
  status (started|done|failed), initiated_by, reason, datetime

pk_outbox                — исходящие команды в PK
  id, account_id, op_type, payload_json, status,
  attempts, next_attempt_at, last_error, created_at, sent_at

webhook_log              — все входящие запросы PK, для аудита
  id, account_id, endpoint, raw_body, headers_json,
  signature_valid, dedup_key, processed_at, created_at

pk_token_cache           — кэш токенов 24ч
  account_id (PK), token, expires_at

5. Event topology (Kafka)

5.1 Адаптер публикует

TopicКогдаPayload ключевое
paykeeper.payment.receivedПришёл informer successorder_id, pk_payment_id, amount, payment_method, paid_at, bank_id?
paykeeper.payment.refundedПришёл callback возвратаorder_id, payment_id, refund_id, amount, status
paykeeper.receipt.fiscalizedПришёл callback §8.13 status=successorder_id, pk_receipt_id, type, fpd, fnd, fn, rnkkt, shift_number, receipt_number, fop_receipt_key, fop_url
paykeeper.receipt.failed§8.13 status=rejected/failed/timeoutorder_id, pk_receipt_id, error.type, error.message
paykeeper.invoice.createdПосле /change/invoice/preview/order_id, pk_invoice_id, pk_invoice_url
paykeeper.account.provisionedAutoinstaller дорисовал ЛКlegal_entity_id, pk_server_host, paykeeper_id

5.2 Адаптер потребляет

TopicИсточникРезультат
order.payment_requestedOrder Service при checkoutСоздание invoice PK
order.refund_requestedOrder Service (админка или POS)Вызов /change/payment/reverse/
order.handed_over (delivery)Order Service (BR 2.5)Вызов /change/payment/post-sale-receipt/
catalog.product.updatedCatalog ServiceUpsert в ims-api PK
catalog.product.deletedCatalog ServiceDelete в ims-api PK
catalog.category.updatedCatalog ServiceUpsert tag в ims-api PK
user.employee.permissions_changedUser ServiceUpsert/delete в /change/user/*

Outbox pattern: при получении consume-события сначала INSERT INTO pk_outbox, затем ack. Worker вытаскивает батчами.

5.3 Консьюмеры в других сервисах

TopicConsumerДействие
paykeeper.payment.receivedOrder Serviceorders.status=paid, orders.paid_at, orders.payment_method
paykeeper.payment.receivedWarehouse ServiceЕсли order.status уже ready — списание остатков (переход с order.completed, см. BR 2.5 §10.1)
paykeeper.payment.receivedCustomer ServiceТочечный пересчёт dynamic групп если customer_id
paykeeper.receipt.fiscalizedOrder Serviceorder_payments.pk_fop_receipt_key, fiscal_data (ФН/ФД/ФП)
paykeeper.payment.refundedOrder Servicerefund_records.status=done

6. Аутентификация и подписи

6.1 Adapter → PK (исходящие)

  • Basic HTTP Auth по каждому запросу: Authorization: Basic base64(pk_login:pk_password) (§6.2). Секреты — из paykeeper_accounts, расшифровываются AES-GCM при вызове.
  • Security token для всех POST (§6.1). TTL 24ч. Хранится в pk_token_cache. При 401/истечении — refresh через GET /info/settings/token/.
  • JWT service-token (§6.3) — для API выплат (OCT) и некоторых sub-API. Отдельный endpoint /info/settings/service-token/?service=oct.
  • Per-account креды — у каждого франчайзи/юрлица свой ЛК → свой base URL ({tsp}.server.paykeeper.ru), логин/пароль/seed. Выбор аккаунта — по franchise_id/legal_entity_id/store_id в исходящем запросе.

6.2 PK → Adapter (входящие)

  • Publicly-exposed endpoints (три отдельных URL для трёх каналов):
    • POST /pk-webhooks/informer/{account_id} — успешная оплата
    • POST /pk-webhooks/refund/{account_id} — завершённый возврат
    • POST /pk-webhooks/receipt/{account_id} — чек в финальном статусе (§8.13)
  • Роутинг по account_id в URL — наш подход. Альтернатива Koala: без account_id в URL, shop_id упакован в orderid (см. §7.2). Наш подход надёжнее при возможной коллизии order-ID между аккаунтами; подход Koala проще при single-endpoint.
  • Dedup: (account_id, dedup_key), где dedup_key = payment_id для informer/refund / receipt_id для receipt callback.
  • Idempotency: повторный запрос с тем же dedup_key → возврат 200 (в правильном формате, см. §6.5) без повторной публикации в Kafka.

6.3 Хранение секретов

  • pk_password, informer_seed — AES-GCM в БД, ключ шифрования из env var PAYKEEPER_SECRETS_KEY (32 байта, загружается из K8s secret / docker secret на деплое).
  • Отдельная таблица для секретов не нужна (поля _enc в paykeeper_accounts).
  • Laravel-аналог в Koala: каст 'encrypted' на полях pk_pass, pk_secret модели IntegrationPayKeeper. Та же идея, другой стек.

6.4 Формула подписи informer success / refund (MD5 legacy)

Боевая формула из Koala_TG_app/Services/PayKeeper/Webhook/Webhook.php:

signatureString =
    id
    + number_format(sum, 2, '.', '')      // "1234.00" (всегда 2 знака, точка, без тысячных разделителей)
    + (clientid ?? '')
    + (orderid ?? '')
    + secret_word                           // informer_seed из §4.3

expected_key = md5(signatureString)         // hex lowercase, 32 символа
valid = hash_equals(expected_key, request.key)

Java/Spring:

String sigString = id
    + new BigDecimal(sum).setScale(2, RoundingMode.HALF_UP).toPlainString()
    + Optional.ofNullable(clientid).orElse("")
    + Optional.ofNullable(orderid).orElse("")
    + secretWord;
String expected = DigestUtils.md5Hex(sigString);     // Apache Commons Codec
boolean valid = MessageDigest.isEqual(expected.getBytes(UTF_8), key.getBytes(UTF_8));

6.5 Формат ответа на informer / refund webhook

Обязателен именно этот формат, иначе PK считает доставку несостоявшейся и продолжает ретраить:

Content-Type: text/plain
Body: "OK " + md5(id + secret_word)

Пример: OK a1b2c3d4e5f6... (префикс OK + пробел + 32 hex).

На ошибку валидации — plain-text описание без префикса OK (Koala возвращает "Hash mismatch", "Order not found", etc., PK ретраит до истечения лимита попыток).

6.6 Формула подписи receipt callback §8.13 (HMAC-SHA256 новая)

Это другая схема, только для callback’а о финальном статусе чека (не путать с informer):

params = {все POST-параметры кроме sign}
sorted_params = ksort(params)                 // лексикографически по ключам
to_hash = implode(';', sorted_params.values)  // значения через точку с запятой
sign = hmac_sha256(to_hash, secret_word)
valid = strtoupper(sign) === strtoupper(request.sign)

7. Multi-tenancy

На уровне ERP: один franchise_id может содержать N юрлиц (BR 1.1), каждое юрлицо — N ТТ.

На стороне PK: один ЛК = один TSP = один набор креденшелов. Практически совпадает с «юрлицо». 1 ЮЛ → 1 ЛК PayKeeper → N терминалов.

7.1 Исходящее направление

  • paykeeper_accounts.legal_entity_id — FK на юрлицо из User Service.
  • Маршрутизация исходящего запроса: store_id → paykeeper_terminals.account_id → paykeeper_accounts (creds + base_url).

7.2 Bridge-ID: упаковка store_id + order_id в orderid

Приём из Koala (OrderIdHelper::pack(order_id, shop_id) / unpack) — при создании invoice мы передаём в PK не чистый UUID заказа, а составной идентификатор, из которого на webhook’е можно восстановить и store, и order:

orderid = pack(store_id, order_id)      // напр. base64url("<store_short>:<order_number>")

Позволяет:

  • Маршрутизировать входящий webhook без account_id в URL (если потребуется).
  • Найти нашу ТТ по orderid даже если PK пришлёт webhook с минимальным payload’ом.
  • Дедуплицировать через (store_id, order_id) вместо глобального payment_id.

У нас в URL webhook’а account_id остаётся (§6.2) для валидации подписи правильным seed’ом; orderid — вторичный bridge-ID внутри payload’а.

7.3 Входящее направление

  • URL {type}/{account_id} → поиск paykeeper_accounts → валидация подписи.
  • orderid из body → unpackstore_id, order_numberOrder в Order Service.

Граничный случай: ТТ поменяла юрлицо (перерегистрация) — нужен новый ЛК PK → новый paykeeper_account. Старые транзакции остаются в старом аккаунте. Свитч прозрачен для ERP (Store.legal_entity_id меняется, mapping обновляется).


8. Надёжность и согласованность

8.1 Проблемы

  • PK может быть недоступен (сетевой лаг, плановые работы).
  • Informer «стучится очень долго пока не дойдёт» (ответ PK на вопрос #3) — но может не дойти до нас совсем (например, адаптер upgrade).
  • Webhook может дублироваться.
  • Кассовое приложение может быть offline — чек в статусе request_sent не перейдёт в success долго.
  • Возврат исполняется асинхронно — инициализация не гарантирует успех (§2.8).

8.2 Паттерны

ПаттернГде
Outboxpk_outbox для всех исходящих команд, гарантия at-least-once
Idempotency keysdedup по payment_id в informer, receipt_id в §8.13, pk_outbox.id в исходящих
Retry with backoffWorker повторяет outbox с exp-backoff, cap 10 попыток, после — DLQ
Circuit breakerResilience4j, открытие при серии 5xx от PK, автоматическое закрытие через 60с
Rate limit (исходящий)Токен-бакет на paykeeper_account_id, по умолчанию 10 RPS/ЛК
Reconciliation (дневной)Cron: раз в сутки GET /info/payments/bydate/?status[]=stuckPOST /change/payment/repeatcnt/ для перезапуска оповещения
Reconciliation (per-invoice)Приём из Koala: после создания инвойса планируется PaymentCheckSchedule с next_run_at = now + 25 min, интервал 5 мин. Поллит GET /info/invoice/byid/ до status=paid или до истечения TTL инвойса
Token cacheВ отличие от Koala (которая запрашивает токен на каждый вызов) — кэш 24ч в pk_token_cache. Рефреш на 401
Compensating actionЕсли outbox-попытка закончилась DLQ — нотификация в админку, ручной retry

8.3 Consistency модель

  • ERP → PK (каталог/пользователи): eventual consistency. Outbox = garantия доставки, но между записью в ERP и появлением в PK — лаг. Допустимо — ERP — source of truth, PK — реплика.
  • PK → ERP (платежи/чеки): eventual consistency через informer + reconciliation. Окно расхождения — минуты/часы в худшем случае (до срабатывания reconcile).
  • Фискальные атрибуты: могут прийти отдельным событием paykeeper.receipt.fiscalized позже, чем paykeeper.payment.received. Order Service должен уметь догружать их.

9. Каталог — deep sync

9.1 Что синхронизируется

ERP → PK (ims-api)Поле PKИсточник
products.namenameCatalog
products.descriptiondescriptionCatalog
products.price (из прейскуранта ТТ)priceCatalog + Store
products.skuskuCatalog
products.barcodebarcodeCatalog
products.vat_rate (NEW)taxCatalog (нужно добавить — gap-analysis §2.1)
products.payment_subject (NEW)item_typeCatalog (NEW)
products.is_markeditem_code_is_mandatoryCatalog
products.unit_of_measuremeasure (с маппингом через measure_pk_override)Catalog
products.tru_code (NEW, маркировка)tru_codeCatalog
categories.name → flat pathtag.nameCatalog (схлопывание дерева в строку)

9.2 Известные ограничения PK (gap-analysis P0)

  • Модификаторы не поддерживаются (P0) → workaround: передаём финальный cart в deep link, кассир на mPOS не может выбирать модификаторы сам.
  • Иерархия категорий плоская (P0) → workaround: путь в имени тега.
  • Per-store прейскуранты нет (P0) → workaround: актуальная цена в cart каждого заказа.
  • Availability per-store (стоп-листы) нет (P0) → workaround: скрыть товар во всех ТТ или не синкать в PK.

9.3 Следствие для моста

Полный каталог в PK — для случая standalone-терминала (кассир бьёт с нуля). Если сценарий «терминал только принимает оплату по готовому cart» — sync каталога не нужен, достаточно передавать cart.

Решение: два режима sync, управляется флагом paykeeper_accounts.catalog_sync_enabled. На MVP — выключен. При включении — батчевая заливка + инкрементальные updates из catalog.product.updated.


10. Онбординг (автоматизация)

10.1 Процесс

  1. Админ франшизы создаёт ЮЛ партнёра в ERP → legal_entity.created event.
  2. Адаптер consume’ит событие → вызов POST /autoinstaller/api/requests с данными ЮЛ (INN, shop name, email).
  3. Поллинг /autoinstaller/api/request_config_fiscal/get_status до статуса ready.
  4. Получены pk_server_host ({tsp}.server.paykeeper.ru), login, password.
  5. Запись в paykeeper_accounts, шифрование секретов.
  6. Автоматический setup informer: POST /change/organization/setting/ с informer_url=https://erp-test.nirbi.ru/pk-webhooks/informer/{account_id} + informer_seed=<рандом 32 байта>.
  7. Добавление email отчётов, email поддержки.
  8. Публикация paykeeper.account.provisioned.

10.2 Что PK подтвердил (ответ #7)

  • Всё, что вручную в ЛК, автоматизируется через 4.*/5.* (/info/organization/*, /change/organization/*).
  • Рекомендация PK: первые внедрения делать вручную для выявления специфики. Автоматизация — Phase 2.

10.3 MVP подход

  • Ручная регистрация ЛК + ручной ввод креденшелов в админке ERP (экран «Интеграции → PayKeeper» из gap-analysis §2.6).
  • Адаптер только использует креды, не провижнит ЛК.

11. Отчётность и сверка

11.1 Источники правды

ДанныеИсточникКак получить
Сумма платежей за день по ТТPKGET /info/systems/sums/?start=...&end=...
Количество платежей по статусуPKGET /info/payments/bydatecount/
Реестр платежей (xlsx)PKGET /export/payments/
Фискальные документыPKGET /info/receipts/bydate/
Связь ERP order ↔ PK paymentERPorder_payments.pk_payment_id (после BR из gap-analysis §2.3)

11.2 Cron reconciliation

  • Каждую ночь (03:00 MSK): per-аккаунт GET /info/payments/bydate/?status[]=stuck&start=yesterday&end=today → для каждого stuck’а POST /change/payment/repeatcnt/ → если через 10 мин не прилетел webhook — ручная догрузка через /info/payments/byid/?id=&advanced=true + публикация paykeeper.payment.received с пометкой reconciled=true в payload.
  • Раз в неделю (понедельник 04:00): сверка сумм GET /info/systems/sums/ vs orders.total в ERP → Slack-алерт при расхождении > 0.5%.

11.3 Per-invoice poll (приём из Koala)

Дополняет дневной cron — быстрое обнаружение успешной оплаты, если informer задерживается:

  • После createInvoice — запись в pk_invoice_check_schedule (order_id, account_id, pk_invoice_id, next_run_at = now + 25 min, interval_min = 5, type = invoice_poll).
  • Worker берёт задачи с next_run_at <= now()GET /info/invoice/byid/?id={pk_invoice_id} → если status=paid и paymentid != null → подгрузка GET /info/payments/byid/ → публикация paykeeper.payment.received (как будто пришёл informer).
  • Удалить запись при успехе или после TTL инвойса (expiry_datetime).

Обоснование 25 мин: у Koala это время подобрано под их TTL. Для нас конфигурируется per-account.


12. Безопасность

12.1 Входящий трафик PK

  • Endpoint POST /pk-webhooks/{type}/{account_id} — публичен за nginx, HTTPS обязателен.
  • Валидация HMAC-подписи → 401 при невалидной.
  • Rate limit per-account_id на nginx: 100 req/min (PK informer настырный, 10-30 попыток для одного платежа).
  • IP whitelist — PK не гарантирует статичный IP, не рекомендуется.
  • Логирование всех запросов в webhook_log (raw body + headers + signature_valid) — retention 90 дней.

12.2 Исходящие данные

  • В логах и Kafka — маскирование CARD_NUMBER (обрезание до 400011xxxxxx1111), informer_seed, pk_password.
  • PII (email, phone) — только в webhook_log.raw_body и paykeeper_payments.raw_informer_json, доступ только через service-account.

12.3 Audit

  • Каждое исходящее P-изменение (create invoice, refund, user update) → запись в pk_outbox + audit_log (who-what-when).
  • Доступ к paykeeper_accounts — только с permission integrations.edit.

13. Deploy-артефакты

АртефактЧто
erp-paykeeper-adapter repoSpring Boot сервис
Docker Compose entry:3015, env PK_SECRETS_KEY, PK_ADAPTER_DB_URL, Kafka brokers
Nginx route/pk-webhooks/ → адаптер
DB migrationpaykeeper_adapter_db с Liquibase changelog
Kafka topics (создать)paykeeper.payment.received, paykeeper.payment.refunded, paykeeper.receipt.fiscalized, paykeeper.receipt.failed, paykeeper.invoice.created, paykeeper.account.provisioned
АдминкаНовый раздел «Интеграции → PayKeeper» (gap-analysis §2.6)
МониторингМетрики per-account: outbox queue size, webhook lag, retry count, 5xx rate

14. Зависимости по сервисам

14.1 Блокеры ДО старта разработки адаптера

ТребованиеСервисСсылка
Поля vat_rate, payment_subject, payment_type, tru_code в productsCataloggap §2.1
Kafka events catalog.product.*, catalog.category.*Cataloggap §2.1
Outbox таблицаCataloggap §2.1
Kafka events user.employee.* + outboxUsergap §2.2
Поля pk_payment_id, pk_fop_receipt_key в order_paymentsOrdergap §2.3
Kafka consumer paykeeper.payment.receivedOrderновое
Mapping store ↔ pk_terminalStoregap §2.4

14.2 Блокеры от PK (gap-analysis P0)

Что ждёмВопрос к PKТекущий статус
Модификаторы в ims-api3.1Не решено
Иерархия категорий3.2Не решено
Per-store прейскуранты3.3Не решено
Availability per-terminal / стоп-листы3.4Не решено
Webhook на возвратPK-summary #2Включается техподдержкой — снято, но сигнатура не в OpenAPI
Webhook на каталогPK-summary #6«В планах PK» — открыто
Ролевая модель LK3.5Не решено — workaround через трансляцию флагов

15. Phasing (факты, не рекомендации)

ФазаЧто включаетБлокеры
P0 — Pilot одна ТТРучной онбординг 1 ЛК PK + приём informer success + /info/receipts/byid/ догрузка фискалки + закрытие заказа в Order. Без sync каталогаGap-analysis §2.3 (Order Service поля), §2.4 (Store ↔ terminal mapping)
P1 — ProductionN точек, reconciliation cron, refund callback (после включения PK), админка «Интеграции», синк сотрудников в ЛК§2.1 (Catalog outbox), §2.2 (User outbox)
P2 — Catalog syncЗаливка каталога в ims-api, поддержание consistency§2.1 полностью + Catalog events
P3 — АвтоонбордингAutoinstaller + авто-провижининг informerНичего дополнительного от нас, обкатать на P1
P4 — DeferredМодификаторы, иерархия, per-store прейскуранты, availabilityБлокеры на PK (gap §3.1-3.4)

16. Открытые вопросы

Закрыто после анализа Koala_TG_app (2026-04-23):

  • Подпись informer — SHA1 или SHA256?MD5 (legacy schema), формула в §6.4. §8.13 HMAC-SHA256 — только для receipt callback.
  • Формат ответа на webhookOK <md5(id+secret)> plain text, §6.5.
  • orderid длина → используем bridge-ID через packing (§7.2) — длина ≤100 символов проверена в боевом коде Koala.
  • Refund webhook существует? → Да, отдельный URL, та же MD5-схема.

Открыто:

  • mpos_terminal_id — есть ли стабильный идентификатор терминала, который можно замапить на store_id? В deep link есть, в informer неочевидно. Koala работает с 1 shop = 1 terminal, у них вопроса нет — у нас возможны >1 терминала на ТТ.
  • Поле bank_id (привязки карт) — нужно ли нам хранить для возврата, или возврат по обычному payment_id достаточен?
  • Кто владеет informer_seed — мы генерим и пушим в ЛК через /change/organization/setting/?name=informer_seed, или он выдаётся PK? Оба варианта работают (Koala хранит готовый, не генерит).
  • Mapping товара ERP → PK: если кассир создал товар в ЛК (чего не должно быть по договорённости), что делаем? Игнорируем / тянем обратно / ругаемся в админку?
  • Formatted amounts: PK возвращает суммы строкой с пробелами ("304 755.00"), десятичный разделитель точка. Клиент моста нормализует через replace(" ","").replace(",",".")BigDecimal. Koala использует number_format($n, 2, '.', '') на входе подписи.
  • 2-стадийный hold: Koala закомментировал PaymentType::TWO_STAGE_HOLD и работает в ONE_STAGE. Когда включать hold у нас?

17. Что уже есть в vault (reusable)

  • _reference/paykeeper/ — 10 разделов JSON API, 2000+ строк.
  • _reference/paykeeper/PK-summary-answers.md — ответы поддержки на 7 вопросов.
  • _assets/paykeeper/gap-analysis.md — 354 строки, стыковка + разрывы.
  • _assets/paykeeper/*.yaml — 9 OpenAPI-спек (ims-api, lk-paykeeper, partner-api, autoinstaller, mpos-deep-links, support-api, api-vyplat, applepay, mirpay).
  • _assets/paykeeper/vision-pos-platform.md, partnership-roadmap-2026.md, business-summary.md — бизнес-контекст.

18. Референс Koala_TG_app — что переиспользуем

Laravel-интеграция github.com/nearbyKoala/Koala_TG_app (PHP, iiko + PayKeeper для Telegram-доставки). Изучена 2026-04-23. Контекст другой (1 магазин = 1 ЛК, iiko-первопричина, Telegram-UI), но PK-коннектор и формулы подписей — боевые, портируем.

18.1 Что переиспользуем напрямую

Артефакт KoalaЧто берёмКак адаптируем
PayKeeperApiService.php (9 методов)Полный список сигнатур методов, payload’ы, обработка 401, retry-политикаПереписываем 1:1 на Java/Spring + RestClient/WebClient. Таблица эндпоинтов — см. §2.1
Webhook::verifySignature() формула MD5Точная формула подписи (§6.4) + формат ответа OK <md5(id+secret)> (§6.5)Прямая реализация в WebhookReceiver адаптера
Webhook поля парсингаПолный список полей informer’а (17 штук — §2.3.1)DTO InformerWebhookDto в адаптере с этими полями
OrderIdHelper::pack/unpackBridge-ID подход — orderid = base64url(store_id:order_number) (§7.2)Реализуем как утилитарный класс в общей либе BFF/адаптер
ImsApiService.php (6 методов каталога)JWT-auth через /info/settings/service-token?service=ims, методы addProduct/getProduct/getProducts/updateProduct/searchProduct/deleteProductПортируем при P2 phase (catalog sync)
PaykeeperPayment.php helperРеференс от самого PK — формат fiscal_cart для 54-ФЗ (tax, quantity, sum, delivery, discounts)Переиспользуем формат при сборке service_name.cart в invoice. Это PK’овая схема — она же нужна в любом адаптере
PayKeeperStatus enumACL-статусы внутри адаптераПереносим как есть: CREATED, PENDING, PAID, REFUNDING, CANCELLED, CAPTURING, CAPTURED, BOUND
PaymentCheckSchedule + next_run_at=+25min, interval=5minPer-invoice poll (§11.3)Реализуем как Kafka Scheduled Tasks / Quartz
IntegrationPayKeeper migration — pk_url, pk_user, pk_pass (encrypted), pk_secret (encrypted)Структура хранения креденшелов + шифрованиеВ наших paykeeper_accounts — поля _enc с AES-GCM, та же идея
Два endpoint’а: POST /payment/webhook, POST /payment/refund-webhookПодтверждение что refund callback работает на той же MD5-схемеУ нас — /pk-webhooks/informer/{account_id}, /pk-webhooks/refund/{account_id}, §6.2
Retry-политика HTTP: retry(3, 1000ms, onConnectionException|timeout)Базовая стратегияResilience4j RetryRegistry с теми же параметрами

18.2 Что НЕ берём

Решение KoalaПочему не берём
Токен запрашивается на каждый вызовWasteful. У нас — pk_token_cache TTL 24ч, refresh на 401
Один магазин = один PK credsУ нас мульти-ТТ / мульти-терминал на ЮЛ. Разделяем paykeeper_accountspaykeeper_terminals
Прямые HTTP-вызовы из Laravel Jobs без outboxРиск потери команд при падении. У нас — pk_outbox + worker
Нет receipt callback §8.13Они не нуждаются в фискальных атрибутах (нет налоговой сверки в vertical’е). У нас — обязательно
Нет /change/payment/post-sale-receipt/У них нет сценария «предоплата → доставка». У нас — Aggregator + BR 2.5 требует
Нет SBP QR (§10.1-10.2)У них оплата только по ссылке. У нас POS BFF может потребовать QR-активацию
Нет sync пользователей в ЛК (/change/user/*)У них нет concept «кассир» — платит клиент Telegram. У нас — кассиры с pos.access
PaymentType::TWO_STAGE_HOLD закомментированУ нас 2-stage нужен для сценариев «холд → капча после выдачи» (BR 2.5 delivery)
MD5 как основа всегоHMAC-SHA256 для receipt callback (§8.13) обязателен, Koala его просто не использует

18.3 Уточнения нашему плану после анализа Koala

  1. §6 Auth: формулы подписей переписаны с гипотетических HMAC-SHA1/256 на реально работающие — MD5 для informer/refund, HMAC-SHA256 для receipt callback (две разных схемы, три разных endpoint’а у нас — §6.2).
  2. §6.5 Response format: "OK <md5(id+secret)>" обязателен — без правильного ответа PK будет бесконечно ретраить.
  3. §7.2 Bridge-ID: добавлен packed orderid = base64url(store_id:order_number) как вторичный идентификатор внутри payload’а PK (в дополнение к account_id в URL webhook’а).
  4. §11.3 Per-invoice poll: добавлен как complement к дневному stuck-cron.
  5. §8.2 Retry: зафиксирован формат 3 попытки × 1с, только ConnectionException/timeout (базовый слой; поверх — Resilience4j для circuit breaker).
  6. §2.3 Поля informer: расширен список реальных полей (17 штук, включая fop_receipt_key, bank_id, bank_payer_id, batch_date, ps_id, card_*, bank_operation_datetime).

18.4 Ссылки на файлы Koala (для цитирования при имплементации)

  • app/Services/PayKeeper/PayKeeperApiService.php — основной клиент
  • app/Services/PayKeeper/Webhook/Webhook.php — парсер + валидация подписи
  • app/Services/ImsApiService.php — каталог (IMS)
  • app/Helpers/PaykeeperPayment.php — fiscal_cart helper (PK official)
  • app/Helpers/OrderIdHelper.php — bridge-ID pack/unpack (нужно прочитать отдельно, не читали полностью)
  • app/Http/Controllers/PayKeeperController.php::webhook/refundWebhook — обработчики
  • app/Http/Controllers/MerchantController.php::createDeliveryInvoice — boot flow
  • database/migrations/2026_02_09_125722_create_integrations_paykeeper_table.php — DB схема
  • app/Enums/PayKeeperStatus.php — enum статусов
  • routes/api.php — маршрутизация