BR 3.3 — PayKeeper Adapter: P0 Pilot (минимальная платёжная петля)

Контекст

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

Что закрыто на 2026-04-23:

  • PK подтвердил cross-device flow через invoice API (POST /change/invoice/preview/ → кассир видит заказ на терминале и принимает оплату) — см. PK-summary-answers #1.
  • PK подтвердил refund webhook (включается техподдержкой) — #2.
  • Формат MD5-подписи informer’а и формат ответа OK <md5(id+secret)> подтверждены боевым кодом nearbyKoala/Koala_TG_app.
  • Reconciliation через stuck-платежи + repeatcnt — рекомендация PK #3.
  • Фискальные поля через /info/receipts/bypaymentid/ + callback §8.13 — #4.
  • Смены ФН управляются кассой PK автоматически, не требуют API — #5.

Архитектура: подробно в Research — PayKeeper bridge architecture (621 строка). Эта BR — первая фаза (P0 — одна пилотная ТТ).

Фазинг:

  • P0 (эта BR) — ручной онбординг 1 ЛК PK, базовая платёжная петля, приём informer + refund, догрузка фискалки.
  • P1+ (будущие BR) — catalog sync в ims-api, user sync в ЛК PK, autoinstaller, post-sale receipt для delivery, SBP QR, 2-stage hold.

Блокеры на стороне PK, актуальные для P0 и нерешённые, документированы в gap-analysis. Для P0 они не блокируют (обходим через передачу cart в invoice).


Бизнес-требования

R1. Ручной онбординг ЛК PayKeeper (per-ЮЛ)

Владелец франшизы или партнёр добавляет интеграцию с PK через новый раздел админки «Интеграции → PayKeeper» в карточке юрлица.

Поля формы:

  • pk_server_host — URL ЛК, вид {tsp}.server.paykeeper.ru (валидация regex)
  • pk_login — логин ЛК
  • pk_password — пароль ЛК (шифруется на лету в БД)
  • informer_seed — секретное слово для подписи webhook’ов (шифруется)

Действия админки:

  • Сохранить — запись в paykeeper_accounts
  • Проверить — попытка GET /info/settings/token/ через переданные креды → успех/ошибка
  • Получить URL webhook’ов — система показывает готовые URL’ы (/pk-webhooks/informer/{account_id}, /pk-webhooks/refund/{account_id}, /pk-webhooks/receipt/{account_id}), которые владелец копирует в ЛК PK

Правила:

  • Один ЛК PK = одно юрлицо (legal_entity_id UNIQUE в paykeeper_accounts)
  • После сохранения интеграция status=active
  • Разрешено только владельцам: пермишен integrations.manage

R2. Mapping ТТ ↔ терминал PK

В карточке ТТ (экран Stores) добавляется секция «PayKeeper» (видна если у ЮЛ есть активная интеграция PK):

  • Поле pk_terminal_id — вписывается вручную (берётся из ЛК PK)
  • Поле pk_mpos_merchant_id — вписывается вручную
  • Один pk_terminal_id = одна ТТ (UNIQUE)

Правила:

  • Пока ТТ не имеет mapping’а — PK-оплата для этой ТТ невозможна (при попытке создать invoice — 422 INTEGRATION_NOT_CONFIGURED)
  • Изменение mapping’а — требует пермишен integrations.manage

R3. Создание инвойса на заказ (ERP → PK)

Когда кассир нажимает «Оплатить» на POS-устройстве (или клиент запрашивает оплату):

  1. Order Service → публикует order.payment_requested (payload: order_id, store_id, cart, total, customer_phone?, customer_email?)
  2. Адаптер consume → POST /change/invoice/preview/ в PK с:
    • pay_amount = total заказа
    • clientid = имя клиента или ID (опц.)
    • orderid = packed bridge-ID = base64url("{store_id_short}:{order_number}")
    • service_name = JSON {cart: [...], user_result_callback: null}
    • client_phone, client_email — если есть
  3. Адаптер сохраняет paykeeper_invoices запись (pk_invoice_id, pk_invoice_url)
  4. Адаптер публикует paykeeper.invoice.created (payload: order_id, pk_invoice_id, pk_invoice_url)
  5. Order Service сохраняет orders.pk_invoice_id, orders.pk_invoice_url, передаёт URL клиенту (для оплаты) или в терминал PK (для bar-flow)

Правила:

  • Таймаут PK API = 30с. При недоступности — записать в pk_outbox и ретраить.
  • Cart формируется по формату PaykeeperPayment.fiscal_cart (см. Koala helper): [{name, price, quantity, sum, tax}]. Модификаторы добавляются как отдельные строки.
  • tax для каждой позиции — из products.vat_rate. Поле добавляется в Catalog (см. R9).

R4. Приём informer’а успешной оплаты (PK → ERP)

PK шлёт POST на https://erp-test.nirbi.ru/pk-webhooks/informer/{account_id} при успешной оплате.

Обработка:

  1. Парсинг body (URL-encoded form-data): 17 полей — id, sum, clientid, orderid, key, service_name, client_email, client_phone, ps_id, batch_date, fop_receipt_key, bank_id, bank_payer_id, card_number, card_holder, card_expiry, bank_operation_datetime.
  2. Валидация MD5-подписи: md5(id + sum(2dp) + clientid + orderid + informer_seed) == key. При несовпадении → 400, логирование.
  3. Dedup по (account_id, pk_payment_id=id). Если уже обработан — сразу успех-ответ без публикации.
  4. Unpack orderidstore_id, order_number → найти Order в Order Service.
  5. Сохранить в paykeeper_payments запись (все поля + raw_informer_json).
  6. Опубликовать Kafka paykeeper.payment.received (payload: order_id, pk_payment_id, amount, payment_method (по ps_id), paid_at, bank_id, card_last4).
  7. Ответить PK: 200 OK "OK <md5(id + informer_seed)>" (plain text, именно этот формат).

Consumer в Order Service: paykeeper.payment.receivedorders.status=paid, orders.paid_at=received_at, orders.payment_method=..., orders.pk_payment_id.

R5. Догрузка фискальных атрибутов

После получения informer’а адаптер асинхронно:

  1. Вызывает GET /info/receipts/bypaymentid/?payment_id={id} в PK.
  2. Для каждого чека (обычно 1 — sale) сохраняет в paykeeper_receipts.
  3. Если status=success — публикует paykeeper.receipt.fiscalized (payload: order_id, pk_receipt_id, type, fpd, fnd, fn, rnkkt, shift_number, receipt_number, fop_receipt_key, fop_url).

Альтернатива: если от PK подключён callback §8.13 — адаптер получает фискалку push’ом на /pk-webhooks/receipt/{account_id} (HMAC-SHA256 валидация). Поведение ниже одинаково.

Consumer в Order Service: paykeeper.receipt.fiscalizedorder_payments.fiscal_data = {fpd, fnd, fn, rnkkt, shift_number, receipt_number}, order_payments.pk_fop_receipt_key.

R6. Приём webhook’а возврата

PK шлёт POST на /pk-webhooks/refund/{account_id} когда возврат (полный или частичный) выполнен.

Обработка:

  1. Парсинг полей (такой же список как informer).
  2. MD5-валидация (та же формула).
  3. Dedup по (account_id, pk_payment_id, refund_sequence).
  4. Unpack orderid → поиск Order.
  5. Запись в paykeeper_refunds (amount, status=done, datetime).
  6. Публикация paykeeper.payment.refunded (payload: order_id, pk_payment_id, amount, refund_total_so_far, is_full_refund).
  7. Ответ PK: OK <md5(id + secret)>.

Consumer в Order Service: paykeeper.payment.refunded → создание refund_records записи (если не существует), status=done.

R7. Инициация возврата из админки (ERP → PK)

Когда сотрудник с пермишеном orders.refund инициирует возврат через Order Service:

  1. Order Service → публикует order.refund_requested (payload: order_id, pk_payment_id, amount, partial, refund_cart?, reason, initiated_by).
  2. Адаптер consume → POST /change/payment/reverse/ в PK с id=pk_payment_id, amount, partial, refund_cart=JSON(...).
  3. PK отвечает {"result":"success"} — возврат запущен async.
  4. Через N минут PK шлёт webhook возврата (R6).

Правила:

  • Если PK возвращает result=fail — публикуется paykeeper.refund.failed с msg. Order Service показывает ошибку в UI.
  • Для частичного возврата refund_cart обязателен (формат PaykeeperPayment.fiscal_cart).

R8. Per-invoice poll как fallback

После paykeeper.invoice.created адаптер планирует проверку:

  • Запись в pk_invoice_check_schedule с next_run_at = now + 25 min, interval_min = 5.
  • Worker каждые 5 мин: GET /info/invoice/byid/?id={pk_invoice_id} → если status=paid и paymentid != null — догружает /info/payments/byid/ и публикует paykeeper.payment.received (если informer ещё не пришёл).
  • Удаление записи при успехе, отмене заказа или по TTL инвойса.

R9. Minimal поля в Catalog для PK

В catalog.products добавляются поля (обязательны для формирования fiscal_cart по 54-ФЗ):

  • vat_rate enum: none | vat0 | vat10 | vat20 | vat110 | vat120 (default vat20)
  • payment_subject enum: goods | service | work | excise | job | payment | agency | composite | another (default goods)
  • payment_type enum: full | prepay | advance | partial_prepay | ... (default full)

Эти поля доступны для редактирования в админке в карточке товара (секция «Фискальные атрибуты», видна если у ЮЛ активна PK-интеграция).

R10. Ночной reconciliation cron

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

  1. GET /info/payments/bydate/?status[]=stuck&start=yesterday&end=today
  2. Для каждого stuck’а — POST /change/payment/repeatcnt/ (просим PK ретайнуть оповещение)
  3. Если через 10 мин informer не пришёл — ручная догрузка через /info/payments/byid/?advanced=true + публикация paykeeper.payment.received с пометкой reconciled=true

Логирование в pk_reconciliation_log.


Ролевая модель

РольДействия
Владелец франшизы (integrations.manage)Создание/редактирование PK-аккаунтов, mapping ТТ ↔ терминал, просмотр логов
Владелец партнёра (integrations.manage в рамках своих ЮЛ)То же, только для своих ЮЛ
Менеджер ТТПросмотр: виден статус интеграции, последние платежи
КассирНевидимо (интеграция работает прозрачно через POS BFF)
Adapter (service)Internal API endpoints с service token

Не в скоупе P0

Явно не делаем в этой BR:

  • Sync каталога ERP → PK ims-api — cart передаётся в invoice напрямую, стандалонного каталога в PK пока не поддерживаем. Phase P2.
  • Sync сотрудников ERP → ЛК PK — кассиры заводятся вручную в ЛК PK. Phase P1.
  • Autoinstaller — ручной онбординг (создание ЛК у PK — вне ERP). Phase P3.
  • Post-sale receipt для delivery-сценариев — в P0 только sale receipt. Phase P1+.
  • SBP QR активация (§10.1-10.2) — не нужна для bar-flow, опц. для POS BFF позже.
  • 2-stage hold (pre-auth + capture) — по умолчанию 1-stage. Phase P1+.
  • Обратный webhook каталога PK → ERP — PK в планах, не реализовано.
  • Привязка карт (§9) — не нужно для MVP.
  • Выплаты OCT (api-vyplat.yaml) — не нужно.

Зависимости

Что достроить в существующих сервисах

СервисЧто
Catalog ServiceПоля products.vat_rate, payment_subject, payment_type + миграция + UI в админке (R9)
Order ServiceПоля orders.pk_invoice_id, pk_invoice_url, pk_payment_id + поле order_payments.fiscal_data (JSONB: fpd, fnd, fn, rnkkt, shift_number, receipt_number) + поле order_payments.pk_fop_receipt_key. Kafka producer order.payment_requested, order.refund_requested. Kafka consumer paykeeper.payment.received, paykeeper.payment.refunded, paykeeper.receipt.fiscalized
Store ServiceПоля stores.pk_terminal_id, pk_mpos_merchant_id (nullable UNIQUE) + UI в карточке ТТ
User ServiceПермишен integrations.manage в каталоге (если ещё нет)
Admin BFF + WebРаздел «Юридические лица → Интеграции PayKeeper», секция «PayKeeper» в карточке ТТ, секция «Фискальные атрибуты» в карточке товара
NginxРоут /pk-webhooks/* → erp-paykeeper-adapter:3015

Новый сервис

erp-paykeeper-adapter — Spring Boot :3015, новая БД paykeeper_adapter_db, см. research §3-4 для структуры.


Критерии приёмки (acceptance)

  1. Онбординг (R1): в пустом ERP админ создаёт PK-аккаунт через форму, система проверяет креды, показывает 3 webhook URL для ввода в ЛК PK.
  2. Mapping (R2): в карточке ТТ вписывается pk_terminal_id, сохраняется, отображается.
  3. Создание инвойса (R3): в тестовой ТТ создаётся заказ → Order Service публикует event → адаптер создаёт invoice в PK (sandbox) → ответ с invoice_url возвращается в Order Service.
  4. Informer (R4): при оплате тестового инвойса → PK шлёт webhook → адаптер валидирует MD5 → публикует Kafka → Order Service закрывает заказ. Повторный webhook с тем же payment_id — не создаёт дубль.
  5. Фискалка (R5): через 30 сек после оплаты order_payments.fiscal_data заполнено fpd/fnd/fn/rnkkt/shift_number/receipt_number.
  6. Refund (R6+R7): сотрудник инициирует возврат в админке → Order Service публикует event → адаптер вызывает PK → через N минут PK шлёт refund webhook → Order Service помечает возврат как done.
  7. Per-invoice poll (R8): при искусственно отключённом informer’е заказ закрывается через 25-30 мин через поллинг.
  8. Reconciliation (R10): ночной cron находит stuck-платежи и вызывает repeatcnt; при продолжительном stuck — догружает через byid и закрывает заказ.

Ссылки