BR 3.3 — Paykeeper Adapter (новый сервис)

Источники

Задачи

Инициализация репо

  • Создать репо erp-paykeeper-adapter в nearbyErp/ GitHub org
  • Добавить в 07-Tasks/Repositories.md
  • Скелет Spring Boot 3.x + Java 21, Maven, Lombok, Liquibase (шаблон по образцу erp-aggregator-service)
  • Docker: Dockerfile (multi-stage build, java-21-slim runtime)
  • .claude/CLAUDE.md с правилами изоляции (по образцу других сервисов)

Миграции БД

  • changelog/001-paykeeper-accounts-and-terminals.xml:
    • paykeeper_accounts (id, legal_entity_id UNIQUE, pk_server_host, pk_login, pk_password_enc BYTEA, informer_seed_enc BYTEA, paykeeper_id, status, onboarded_at, last_token_at, timestamps, deleted_at)
    • paykeeper_terminals (id, account_id FK, store_id UNIQUE, pk_terminal_id UNIQUE, pk_mpos_merchant_id, label, status, timestamps)
    • Индексы по §Data Model
  • changelog/002-invoices-payments-receipts-refunds.xml:
    • paykeeper_invoices, paykeeper_payments, paykeeper_receipts, paykeeper_refunds
  • changelog/003-outbox-logs-schedulers.xml:
    • pk_outbox, webhook_log, pk_token_cache, pk_invoice_check_schedule

Entities + Repos

  • JPA entities для всех 10 таблиц
  • Spring Data репозитории с custom queries для outbox worker’а и scheduler’а

Криптография секретов

  • SecretVault utility — AES-GCM шифрование pk_password/informer_seed с ключом из PAYKEEPER_SECRETS_KEY env
  • Entity-level @Convert для автоматической дешифровки при чтении (или явный метод — на выбор)

HTTP-клиент PayKeeper

  • PayKeeperClient — RestClient/WebClient с Basic auth + form-urlencoded POST + JSON response
  • Token cache: Redis key pk:token:{account_id} TTL 24ч, refresh на 401
  • Retry: 3 попытки × 1000ms на ConnectionException/timeout
  • Circuit breaker: Resilience4j, open при серии 5xx
  • Все 10 методов из Koala’s PayKeeperApiService (§18.1 research): getAuthToken, createInvoice, getInvoiceById, sendInvoice, getPaymentById, reversePayment, changePaymentCapture, infoOptionsById, changeBindingExecute + дополнительно getReceiptsByPaymentId, repeatcnt, getPaymentsByDate для reconciliation

Webhook-receiver (публичный)

  • Controllers:
    • POST /pk-webhooks/informer/{account_id} — парсинг form-data в InformerWebhookDto (17 полей), MD5-валидация, dedup, publish Kafka, ответ "OK <md5(id+secret)>"
    • POST /pk-webhooks/refund/{account_id} — тот же handler, другая публикация
    • POST /pk-webhooks/receipt/{account_id} — HMAC-SHA256 валидация, парсинг полей receipt
  • SignatureValidator — MD5 по формуле research §6.4 + HMAC-SHA256 по §6.6
  • Логирование всех запросов в webhook_log независимо от исхода (for audit)
  • Rate limit per-account_id (Resilience4j)

Admin API

  • AccountController/internal/paykeeper/accounts/* CRUD + suspend/resume/test-connection (см. API.md)
  • TerminalController/internal/paykeeper/terminals/* CRUD
  • LogsController/internal/paykeeper/accounts/{id}/logs
  • InternalController/internal/paykeeper/accounts/by-legal-entity/{id}, /terminals/by-store/{id}, /refunds/{id}/retry
  • Auth через Bearer JWT (декодер как в других сервисах) + service-token

Outbox-worker

  • PkOutboxWorker@Scheduled раз в 3 сек
  • Пачками по 20 записей со status=pending AND next_attempt_at <= now() FOR UPDATE SKIP LOCKED
  • Вызов PK по op_type: create_invoice / reverse_payment / capture / repeatcnt / post_sale_receipt
  • При успехе status=done, sent_at=now()
  • При ошибке — backoff (exp: 10s, 30s, 2m, 10m, 1h, 6h, 24h), attempts++, max 10 → status=dead_letter
  • DLQ notification через Kafka на Admin BFF

Kafka consumers

  • OrderPaymentRequestedConsumer (group paykeeper-adapter-invoice) — insert в pk_outbox с op_type=create_invoice
  • OrderRefundRequestedConsumer (group paykeeper-adapter-refund) — insert в pk_outbox с op_type=reverse_payment

Kafka producers

  • PkEventPublisher публикует: paykeeper.invoice.created, paykeeper.payment.received, paykeeper.payment.refunded, paykeeper.receipt.fiscalized, paykeeper.receipt.failed, paykeeper.refund.failed, paykeeper.account.provisioned

Fiscal fetcher

  • FiscalFetcher — async после paykeeper.payment.receivedGET /info/receipts/bypaymentid/ → сохранение в paykeeper_receipts → publish paykeeper.receipt.fiscalized / paykeeper.receipt.failed

Per-invoice poll scheduler

  • InvoiceCheckScheduler — раз в 5 мин выбирает записи pk_invoice_check_schedule со status=active AND next_run_at <= now()
  • Вызов GET /info/invoice/byid/ → если paid — догрузка платежа + publish paykeeper.payment.received (reconciled=true)
  • Удаление записи при paid / cancelled / expired

Ночной reconciliation cron

  • StuckPaymentsReconcileJob — cron 0 0 3 * * ?
  • Per account: GET /info/payments/bydate/?status[]=stuck + POST /change/payment/repeatcnt/
  • Логирование в pk_reconciliation_log

Retention / чистка

  • WebhookLogRetentionJob — ежесуточная чистка webhook_log старше 90 дней
  • OutboxRetentionJob — чистка pk_outbox status=done старше 30 дней

Тесты

  • Unit-тесты: SignatureValidator (MD5 с Koala-vectors), OrderidPacker, маппинги PK polish→BigDecimal
  • Integration-тесты: WireMock для PK, Testcontainers для Postgres/Kafka
  • E2E: полный цикл invoice → informer → receipt через fake PK

Деплой

  • Docker image в registry
  • Env на VPS: PAYKEEPER_SECRETS_KEY (AES ключ), WEBHOOK_BASE_URL, DB creds
  • Docker-compose добавить сервис
  • Nginx route /pk-webhooks/*:3015
  • Liquibase migrations применить
  • Kafka topics создать (7 топиков)

Критерии приёмки

  • Создание PK-аккаунта через API возвращает 3 webhook URL
  • Тестовый invoice создаётся в sandbox PK и возвращает invoice_url
  • Informer с валидной MD5-подписью → paykeeper.payment.received публикуется, webhook отвечает OK <md5>
  • Повторный informer с тем же pk_payment_id не публикует дубль, отвечает OK <md5>
  • Фискальные атрибуты подтягиваются через 30 сек после оплаты
  • Refund через POST /change/payment/reverse/ → через N минут приходит refund webhook → RefundRecord.status=done
  • При искусственно отключённом informer — заказ закрывается через per-invoice poll через 25-30 мин
  • Ночной cron находит stuck-платежи и вызывает repeatcnt