# Интеграции магазина: передача заказов и приём оплаты

Новый заказ и смена его статуса уходят из teeu в систему магазина. Настраивается продавцом в
кабинете: **Интеграции** (`/{SELLER_PANEL_PREFIX}/integrations`).

Интеграций у магазина может быть несколько и разных форматов сразу — Битрикс24 для менеджеров и
собственный обработчик для склада или аналитики не мешают друг другу. Каждая получает свою задачу в
очереди, поэтому отказ одной не задерживает остальные, и автопауза у каждой своя. Предел —
`teeu.integrations.max_per_seller`.

Передача односторонняя: статус в teeu продавец по-прежнему меняет в кабинете. Входящий API —
отдельная задача.

## Типы и группы

Типы делятся на группы (`IntegrationGroup`), это видно в кабинете: **CRM и учётные системы** —
заказ уходит со всеми полями; **Мессенджеры** — короткое уведомление в чат; **Приём оплаты** —
эквайринг продавца. У первых двух механика общая, различаются транспортом и составом настроек.

| Формат | Метод | Что нужно продавцу |
|---|---|---|
| `json` | POST на его адрес | свой обработчик (скрипт, n8n, Make) |
| `telegram` | сообщение ботом teeu в чат | добавить бота и подтвердить привязку |
| `max` | то же в мессенджере MAX | добавить бота и подтвердить привязку |
| `amocrm` | `/api/v4/leads/complex` + примечание | поддомен и долгосрочный токен |
| `bitrix24_lead` | `batch`: лид + товары | вставить входящий вебхук портала |
| `bitrix24_deal` | `batch`: контакт + сделка + товары | то же |
| `yookassa` | приём оплаты картой | shopId и секретный ключ из кабинета ЮKassa |

Реализации — `app/Services/Seller/Integrations/*Transport.php`, выбор по формату делает
`IntegrationTransports`. Добавить формат — дописать case в enum `IntegrationType` и класс транспорта.

**Эквайринг — не транспорт.** Соблазн переиспользовать `IntegrationTransport` был, но задачи
разные: транспорт отвечает на «куда сообщить о заказе», эквайринг — на «как получить по нему
деньги и как узнать, что они пришли». Общее у них только хранилище ключей, им общее и осталось:
`SellerIntegration` с шифрованными `credentials`, а логика — в `Services\Payments\YooKassaProvider`
([payments.md](payments.md)). Чтобы эквайринг не попал в очередь доставки заказов,
`SellerIntegration::isDeliverable()` отсекает его явно.

## События

| Событие | Когда | Кому доступно |
|---|---|---|
| `order.created` | покупатель оформил заказ | всем форматам |
| `order.status_changed` | статус изменился (в теле — `previous_status`) | всем форматам |
| `chat.message` | покупатель написал в чат магазина | только мессенджерам |
| `ping` | продавец нажал «Отправить проверочное» | всем форматам |

Источник — существующие `OrderCreated` и `OrderStatusChanged`; слушатели подключены
авто-обнаружением, вручную в `AppServiceProvider` их регистрировать нельзя (будет двойная отправка).

### Сообщения из переписки

Продавец не сидит в кабинете весь день, а вопрос перед покупкой живёт недолго. Письмо о
непрочитанном приходит через час (`teeu:chat:remind-unread`) — этого хватает, чтобы диалог не
потерялся, но мало, чтобы ответить сразу. Поэтому сообщение можно получать в мессенджер:
галочка **«Сообщение от покупателя в чате»** (`notify_message`), по умолчанию выключенная даже
у Telegram и MAX — переписка идёт куда чаще заказов, и включать её самим значило бы внезапно
засыпать чат тому, кто настраивал уведомления о заказах.

Четыре решения, которые видно только в коде:

**Событие называется `ChatMessagePublished`, а не «отправлено».** Диспатчится из
`ModerateChatMessageJob` в момент, когда модерация одобрила сообщение. До этого его не видит
никто, кроме автора (ТЗ §30.5): уведомить раньше — позвать продавца в диалог, где для него пусто.
Заблокированное сообщение не уходит вовсе.

**Только от покупателя.** Свои же сообщения продавцу возвращать незачем, «Команда teeu» —
вмешательство площадки со своим оповещением, служебные заметки видны лишь в кабинете. Диалог с
поддержкой пропускается: магазина в нём нет.

**`ChatNotifier` — отдельный интерфейс**, не метод в `IntegrationTransport`. Сообщение осмысленно
только в мессенджере; заставлять Битрикс и amoCRM реализовывать заглушку значило бы утверждать,
что они это умеют. Галочки в их форме тоже нет, а `IntegrationType::supportsChatMessages()` —
единственное определение «кому это доступно».

**Доставка — та же джоба.** `DeliverSellerIntegration` принимает готовый текст (`$text`) вместо
заказа, и повторы, журнал и автопауза работают ровно как у заказов. Записи в журнале — с событием
`chat.message`.


## Формат `json`

POST, `Content-Type: application/json`. Заголовки:

| Заголовок | Значение |
|---|---|
| `X-Teeu-Event` | `order.created`, `order.status_changed`, `ping` |
| `X-Teeu-Delivery` | uuid попытки |
| `X-Teeu-Timestamp` | unix-время отправки |
| `X-Teeu-Signature` | `sha256=<hmac_sha256(секрет, сырое тело)>` |

Тело:

```json
{
  "version": 1,
  "event": "order.created",
  "delivery_id": "9f1c…",
  "sent_at": "2026-08-12T10:00:00+03:00",
  "order": {
    "number": "TU-FPTAMQIB",
    "status": "new",
    "status_label": "Новый",
    "created_at": "2026-08-12T09:59:00+03:00",
    "total": "38012.00",
    "currency": "RUB",
    "url": "https://teeu.ru/merchant/orders/17",
    "buyer": { "name": "Иван", "phone": "+79001234567" },
    "delivery": {
      "carrier_code": "cdek", "point_code": "MSK123",
      "point_address": "Москва, Тверская 1", "cost": "350.00",
      "note": null, "tracking_number": null
    },
    "pickup_points": [
      { "carrier_code": "cdek", "name": "ПВЗ на Тверской", "address": "…", "priority": 1 }
    ],
    "items": [
      { "title": "Наушники Sony", "vendor_code": "WH-1000",
        "quantity": 2, "unit_price": "500.00", "total_price": "1000.00" }
    ]
  },
  "previous_status": "new"
}
```

`vendor_code` — артикул из вашей же выгрузки: по нему ваша система сопоставит позицию со своей
номенклатурой.

**`version` повышается только при несовместимом изменении состава.** Добавление новых полей версию
не меняет — разбирайте тело так, чтобы незнакомые поля не ломали обработчик.

### Проверка подписи

Считайте HMAC от **сырого тела запроса**, а не от пере-закодированного JSON: иначе подпись не
сойдётся из-за порядка ключей и экранирования.

```php
$raw = file_get_contents('php://input');
$expected = 'sha256='.hash_hmac('sha256', $raw, $secret);

if (! hash_equals($expected, $_SERVER['HTTP_X_TEEU_SIGNATURE'] ?? '')) {
    http_response_code(403);
    exit;
}

$order = json_decode($raw, true)['order'];
```

### Ответ обработчика

Любой код 2xx — доставлено. Код 4xx считается отказом по существу и **не повторяется** (менять
нечего). 5xx и обрыв связи — повтор по расписанию `30 с → 5 мин → 30 мин → 2 ч`.

Обработчик должен быть идемпотентным: при потере ответа повтор придёт с тем же `delivery_id`.

## Форматы Битрикс24

Адрес берётся в портале: **Разработчикам → Другое → Входящий вебхук**, право доступа **CRM**.
Метод на конце адреса можно оставить — мы отрезаем его и подставляем нужный, поэтому одного адреса
хватает и для сделки, где вызовов несколько.

**Адрес — это секрет:** в нём токен доступа к CRM. Поэтому он хранится зашифрованным
(каст `encrypted` у модели) и нигде не печатается целиком — ни в интерфейсе, ни в журнале, ни в логах.
Подпись для этих форматов не нужна: проверить её Битриксу нечем, подлинность держится на секретности
адреса.

**Лид** — `batch` с `halt=1`: `crm.lead.add` → `crm.lead.productrows.set` (по ссылке
`$result[lead]`). Контакты покупателя в полях лида, номер заказа в `TITLE`, состав — товарными
строками.

**Сделка** — `batch` с `halt=1`: `crm.contact.add` → `crm.deal.add` (контакт через `CONTACT_IDS`,
ссылкой `$result[contact]`) → `crm.deal.productrows.set`. Одним пакетом, чтобы сбой посередине не
оставил сделку без товаров.

Разница между форматами — не в товарах (их поддерживают оба), а в сущности: сделка сразу встаёт в
воронку и привязывается к контакту. Лид проще: заявка, которую менеджер обзванивает.

`crm.lead.productrows.set` помечен в документации Битрикса как устаревший в пользу универсального
`crm.item.productrow.*`. Пока работает; выбран потому, что ставит все позиции одной командой, а
универсальный метод — по одной за вызов, и заказ на полсотни позиций упёрся бы в лимит пакета.
Если метод уберут — это и будет путь миграции.

Смена статуса не создаёт вторую запись: ищем существующую по номеру заказа в заголовке и пишем
комментарий в таймлайн (`crm.timeline.comment.add`).

### Чего Битрикс не умеет, и что из этого следует

**У товарной строки нет поля для артикула** — только `PRODUCT_ID` из каталога самого портала,
которого teeu не знает. Поэтому артикул дописывается в название позиции: «Наушники Sony (арт.
WH-1000)». Автоматическое сопоставление с номенклатурой — отдельная задача.

**Пунктам выдачи в CRM места нет** — они уходят в `COMMENTS` вместе со ссылкой на заказ в кабинете.
Состав заказа там не дублируется: он в товарных строках.

**Стадию воронки не выставляем.** Идентификаторы стадий у каждого портала свои, угадать соответствие
статусам teeu нельзя. Статус приходит комментарием в таймлайн.

**Отказ приходит телом.** Битрикс отвечает `{"error": …}`, причём код бывает и 200. Успех
определяется по телу; иначе журнал уверял бы продавца, что всё доставлено. Ошибки
`QUERY_LIMIT_EXCEEDED`, `OPERATION_TIME_LIMIT` и `INTERNAL_SERVER_ERROR` считаются временными и
повторяются, остальные — нет.

**Идемпотентности нет.** Если запрос дошёл, а ответ потерялся, повтор создал бы второй лид. Поэтому
начиная со второй попытки сначала ищем запись по номеру заказа и, найдя, просто фиксируем её id.

## amoCRM

Модель доступа здесь принципиально иная, чем у Битрикса: «входящего вебхука», в который можно просто
постить, у amoCRM нет. Есть OAuth и — с 2024 года — **долгосрочный токен** для приватных интеграций.
Выбран второй путь: OAuth потребовал бы от teeu публичной интеграции с модерацией в маркете amoCRM,
а от продавца — лишних шагов и последующего обновления токенов.

**Что вводит продавец:** поддомен аккаунта (принимаем и полный адрес — вырезаем сами) и токен.
В amoCRM он выпускается так: Настройки → Интеграции → создать интеграцию → вкладка «Ключи и
доступы» → «Сгенерировать токен», срок до пяти лет. Показывается один раз, поэтому пустое поле при
сохранении означает «оставить прежний», а не «стереть».

Поле токена (как и секретного ключа ЮKassa) — `<x-secret-input>`, а не `type="password"`. С полем
пароля браузер принимал форму за форму входа и подставлял пароль продавца от teeu, а раз пустое поле
значит «оставить прежний», сохранение молча заменило бы рабочий токен этим паролем.

**Что происходит при заказе:** `POST /api/v4/leads/complex` создаёт сделку вместе с контактом
(не более одного контакта на сделку — нам хватает), затем `POST /api/v4/leads/{id}/notes` добавляет
состав примечанием. Через создание сделки примечания не заводятся, поэтому вызова два.

Телефон кладём через `field_code: PHONE`, а не по идентификатору поля: у каждого аккаунта свои id,
а код системного поля одинаков везде.

**Товарных позиций нет.** В amoCRM товары живут отдельным каталогом и связываются по
идентификаторам его элементов, которых teeu не знает. Поэтому состав уходит текстом в примечание —
так же, как это было бы с любой номенклатурой, не сопоставленной заранее.

**Отозванный токен — отказ, а не сбой.** Ответы 401 и 403 не повторяются: пока продавец не выпустит
новый токен, повторы бессмысленны. 429 и 5xx — временные, уходят в повтор.

Перед повтором ищем уже созданную сделку по номеру заказа (`GET /api/v4/leads?query=…`):
идемпотентности у amoCRM нет, и потерянный ответ иначе создал бы вторую сделку.

## Telegram

Бот **наш, teeu** (настройки `telegram.seller_bot_token` и `telegram.seller_bot_username`,
отдельный от админского). Продавец не заводит своего: инструкция «сходите к BotFather» отсекла бы
половину неайтишных магазинов.

**Привязка чата.** Бот один на всех, поэтому «бот добавлен в чат» само по себе не говорит, чей это
чат. Продавец получает одноразовый код и отправляет боту `/start <код>` — в личке через ссылку
`https://t.me/<бот>?start=<код>`, в группе сообщением. Код живёт 15 минут и сгорает при
использовании: иначе утёкшая ссылка позволила бы чужому чату подписаться на заказы магазина.

**Приём апдейтов.** `POST /integrations/telegram/updates`, подлинность — по заголовку
`X-Telegram-Bot-Api-Secret-Token`, а не по секрету в адресе: путь попадёт в логи nginx, заголовок —
нет. Маршрут исключён из проверки CSRF (у Telegram нашего токена нет и быть не может). Регистрация
адреса — командой `teeu:telegram:set-webhook`; она же генерирует секрет при первом запуске.

**Адрес Bot API — настройка, а не константа** (`telegram.base_url`, пусто → `https://api.telegram.org`).
С нашего хостинга прямой `api.telegram.org` недоступен: DNS отдаёт IPv6, маршрута к нему нет, а по
IPv4 TCP-соединение устанавливается, но TLS обрывается по таймауту — блокировка по SNI. Поэтому в
настройку вписывается зеркало, и она общая для админских алертов, уведомлений продавцам и
регистрации вебхука — иначе половина вызовов ходила бы мимо. Если алерты вдруг замолчали, проверять
надо в первую очередь это.

**Токен маскируется в текстах ошибок** (`TelegramApi::mask`). Guzzle кладёт в сообщение исключения
полный URL, а в нём токен бота: без маски он утекал в `laravel.log` и — что хуже — в журнал
доставок, который открыт продавцу.

**Лимиты теперь наши.** Бот общий, значит ограничения Telegram на частоту расходуются всеми
продавцами сразу. Ответ `429` с `retry_after` считаем временной ошибкой — очередь сама растянет
отправку, вместо того чтобы копить неудачи и уводить интеграцию в автопаузу.

**Что уходит в чат.** Короткая выжимка: номер, сумма, до десяти позиций, покупатель, пункт выдачи и
ссылка на заказ. Структуру шлём в CRM, в чат — то, что читает человек. По умолчанию у мессенджера
включены только новые заказы: смена статуса в чате шумит, особенно когда её же продавец и делает.

## MAX

Устроен как Telegram: наш бот, привязка чата одноразовым кодом, приём событий с проверкой
заголовка (здесь — `X-Max-Bot-Api-Secret`, значение задаётся при подписке `POST /subscriptions`).
Подписка оформляется командой `teeu:max:subscribe`, у неё же есть `--list` и `--drop`.

**Три вещи, на которых легко ошибиться по аналогии с Telegram** — все проверены на живом API:

1. **Получатель передаётся в строке запроса:** `POST /messages?chat_id=…`. В теле только текст.
2. **Токен идёт в заголовке `Authorization` как есть** — без схемы `Bearer` и без вставки в путь.
3. **`user_id` и `chat_id` — разные идентификаторы.** Отправка на `?chat_id=<user_id>` отвечает
   `chat.not.found`; у личного диалога свой `chat_id`, и именно он приходит в событии привязки.
   В ответе на успешную отправку он виден в `message.recipient.chat_id`.

**Базовый адрес — `platform-api.max.ru`.** В документации указан `platform-api2.max.ru`, но этот
хост не отвечает вовсе. Поэтому адрес вынесен в настройку `max.base_url`: если они всё-таки
переедут, это правка в панели, а не выкатка.

Идентификатор отправленного сообщения возвращается в `message.body.mid` — он попадает в журнал
доставок как ссылка на запись.

## Автопауза

Подряд `teeu.integrations.max_failures` неудач — эндпоинт останавливается сам, продавец видит плашку с
причиной, админу уходит телеграм-алерт (`telegram.event.webhook_paused`). Сохранение настроек снимает
паузу и обнуляет счётчик.

## Журнал

Каждая попытка пишется в `seller_integration_deliveries` вместе с ответом и id созданной записи —
продавец видит его в кабинете и находит заказ у себя. Чистится по расписанию
(`teeu:webhooks:prune-log`, срок в `teeu.integrations.log_days`): запись на каждую попытку растёт
быстрее самих заказов.

## Настройки

`config/teeu.php`, секция `integrations`: `timeout`, `max_failures`, `log_days`, `log_page_size`.
