# Удаление legacy прямого webhook-канала (`ProcessWebhookJob`)

**Дата:** 2026-05-24
**Статус:** Design (awaiting user review)
**Автор:** Claude Opus 4.7 (под руководством заказчика)
**Брейнсторм:** сессия 2026-05-24
**Триггер:** в коде осталось legacy-расхождение от старой prepaid-схемы. Изначально планировалась унификация под always-rub; в ходе брейнсторма выяснилось, что код — рудимент, не часть актуальной архитектуры каналов.

---

## §1. Контекст и проблема

### §1.1 Архитектура каналов приёма лидов

Лидерра принимает лиды через **два канала**:

| Канал | Назначение | Реализация | Биллинг |
|---|---|---|---|
| Основной | Real-time приём от `crm.bp-gr.ru` | `SupplierWebhookController::receive` → `INSERT supplier_leads` → `RouteSupplierLeadJob::dispatch` | `LedgerService` (always-rub) |
| Резервный | Часовая доливка пропусков через CSV-отчёт `crm.bp-gr.ru` | `CsvReconcileJob` → доливает `supplier_leads` → `RouteSupplierLeadJob` | `LedgerService` (always-rub, косвенно через основной) |

Оба канала **уже** на единой биллинг-логике — рубли + 7-ступенчатая тарифная шкала через `LedgerService::chargeForDelivery`.

### §1.2 Что осталось от старой архитектуры

До эпика «шеринг» (Plan 2/5) в коде была другая модель: у каждого тенанта был свой `webhook_token`, поставщик стучался напрямую `POST /api/webhook/{token}` → `WebhookReceiveController` → `ProcessWebhookJob` → списание из `tenants.balance_leads` (штучный prepaid-баланс).

После шеринг-эпика этот путь **перестал использоваться**, но из кода не убран:

- `ProcessWebhookJob` ([app/app/Jobs/ProcessWebhookJob.php](../../../app/app/Jobs/ProcessWebhookJob.php), 342 строки) — на старой prepaid-модели (`tenant->decrement('balance_leads')`, `BalanceTransaction(amount_leads=-1)`).
- `WebhookReceiveController` + публично открытый роут `POST /api/webhook/{token}` ([app/routes/web.php:276](../../../app/routes/web.php#L276)).
- Таблицы `webhook_log` (партиционированная, 13 партиций) и `webhook_dedup_keys` — источник записей только этот job.
- Колонки `tenants.webhook_token`, `tenants.webhook_token_rotated_at`.
- Тесты `ProcessWebhookJobTest`, `WebhookReceiveTest` (плюс упоминания в 4 других файлах-тестах).

**Подтверждение «не используется на проде»:**

- `SELECT COUNT(*) FROM webhook_log` = 0 (за всю историю боевого сервера).
- `last_webhook_at` у всех 5 тенантов = NULL.
- Боевые лиды (`deals.tenant_id=2`, 412 шт.) пришли через основной канал — `source_crm_id` имеет формат `vid` от `crm.bp-gr.ru`.

### §1.3 Проблемы рудимента

1. **Открытый публичный эндпоинт.** `POST /api/webhook/{token}` доступен из интернета без middleware-проверки, аутентификация по знанию токена в URL. Лишний attack surface для DAST/нагрузочных атак.
2. **Блокирует Phase B Спека A.** Phase B = `ALTER TABLE tenants DROP COLUMN balance_leads`. После него `ProcessWebhookJob` сломается на первом же вызове (или на запуске тестов).
3. **Расхождение биллинг-моделей в коде.** Два разных списания на одну и ту же сущность (`Deal`) — путаница для будущих изменений.
4. **Test-debt.** Тесты на старую prepaid-модель продолжают занимать набор тестов и время CI.

### §1.4 Решение

Удалить рудимент целиком (код + контроллер + роут + модель `WebhookDedupKey` + связанные тесты + таблицы БД + колонки `tenants`). Одним PR, выкатка одним релизом.

---

## §2. Scope

### §2.1 Что удаляем

**PHP-код:**

- `app/app/Jobs/ProcessWebhookJob.php` (целиком)
- `app/app/Http/Controllers/Api/WebhookReceiveController.php` (целиком, если используется ТОЛЬКО для legacy-роута)
- роут `Route::post('/api/webhook/{token}', ...)` в [app/routes/web.php:276](../../../app/routes/web.php#L276)
- `app/app/Models/WebhookDedupKey.php` (целиком — используется только `ProcessWebhookJob`)
- `app/app/Mail/LowBalanceNotification.php`, `app/app/Mail/ZeroBalanceNotification.php` — **только если impact-check (§3) подтвердит, что нет других caller'ов**

**Методы в `NotificationService`** (только если impact-check подтвердит, что нет других caller'ов):

- `notifyLowBalance`
- `notifyZeroBalance` (NB: не путать с `notifyZeroBalancePaused` — это разный метод шеринг-канала, оставляем)
- `notifyNewLead` — **оставляем** (использует и шеринг через `RouteSupplierLeadJob`)

**Тесты (целиком):**

- `app/tests/Feature/ProcessWebhookJobTest.php`
- `app/tests/Feature/WebhookReceiveTest.php`

**Тесты (частично — удалить только релевантные кейсы, проверить не опустели ли файлы):**

- `app/tests/Feature/Pd/DealCreatePdLogTest.php`
- `app/tests/Feature/Notifications/BalanceNotificationsTest.php`
- `app/tests/Feature/Notifications/NewLeadNotificationTest.php`
- `app/tests/Feature/Notifications/InAppNotificationTest.php`

**Миграция БД (одной миграцией, идемпотентной):**

- `DROP TABLE webhook_log` (партиционированная) + все 13 партиций
- `DROP TABLE webhook_dedup_keys`
- `DROP TABLE rejected_deals_log` — **только если impact-check подтвердит, что писалось только `ProcessWebhookJob`**
- `ALTER TABLE tenants DROP COLUMN webhook_token, DROP COLUMN webhook_token_rotated_at` — **только если impact-check подтвердит, что не используется в UI/API**
- удаление сидов / system_settings ключей, относящихся только к legacy: `low_balance_threshold_leads` (если не унифицируется в рамках другого спека)

### §2.2 Что НЕ трогаем (явно out of scope)

- `failed_webhook_jobs` — используется `RouteSupplierLeadJob::failed()` (шеринг-канал).
- `SupplierLeadCost` — пишется и шеринг-каналом (через `LedgerService::chargeForDelivery`).
- `MonthlyPartitionManager` — управляет партициями нескольких таблиц, не только `webhook_log`.
- `SupplierResolver` — используется в админке (`AdminSupplierIntegrationController`).
- Phase B Спека A (`DROP COLUMN balance_leads`) — отдельная задача после ≥72ч наблюдения Phase A.
- Унификация `notifyLowBalance` под рубли — отдельная задача, если решим возрождать low-balance уведомления.

---

## §3. Impact-checks (обязательны перед удалением)

Каждая удаляемая сущность пройдёт автоматическую проверку «не использует ли её живой код». Список проверок — задачи в плане:

| Сущность | Проверка | Решение если есть use |
|---|---|---|
| `WebhookReceiveController` | `grep -r "WebhookReceiveController" app/` | Если есть use вне роута — удалить только метод `receive`, контроллер оставить |
| `NotificationService::notifyLowBalance` | `grep -r "notifyLowBalance" app/` | Если есть caller вне `ProcessWebhookJob` — оставить метод |
| `NotificationService::notifyZeroBalance` | `grep -r "notifyZeroBalance\b" app/` (с word boundary, чтобы не зацепить `notifyZeroBalancePaused`) | Если есть caller — оставить метод |
| `LowBalanceNotification`, `ZeroBalanceNotification` (Mailable) | `grep -r "LowBalanceNotification\|ZeroBalanceNotification" app/` | Если есть use — оставить класс |
| `tenants.webhook_token`, `webhook_token_rotated_at` | grep по `app/` и `app/resources/js/` — поиск в UI, API-resource, ресурс-сериализаторах, фабриках, сидах | Если есть UI/API consumer — отдельная задача на удаление UI |
| `rejected_deals_log` | `grep -r "RejectedDealsLog\|rejected_deals_log" app/` | Если есть use вне `ProcessWebhookJob` — таблицу не дропать |
| `webhook_dedup_keys` | `grep -r "webhook_dedup_keys\|WebhookDedupKey" app/` | Должен быть пустым после удаления `ProcessWebhookJob` |
| `low_balance_threshold_leads` (system_settings) | `grep -r "low_balance_threshold_leads" app/` | Если есть caller — мигрировать или удалить настройку |

Все проверки делаются на текущем коде (после mental-удаления `ProcessWebhookJob` и тестов).

---

## §4. Решение по архитектуре

### §4.1 Главный инвариант

После выпиливания **остаётся ровно одна труба биллинга**: `RouteSupplierLeadJob::createDealCopyForProject` → `LedgerService::chargeForDelivery`. Все списания всех каналов идут через неё.

### §4.2 Что меняется в публичном API

- `POST /api/webhook/{token}` — **404**. Старые токены тенантов перестают принимать вход (попадание в логи nginx как 404 — это нормально, на проде вызовов 0).
- `POST /api/webhook/supplier/{secret}` — **без изменений** (это шеринг-канал от `crm.bp-gr.ru`).

### §4.3 Откатываемость

Миграция БД **необратимая** (DROP TABLE / DROP COLUMN). Бэкап `pg_dump` снимается перед выкаткой по runbook `docs/deploy/test-server-runbook.md`. В случае critical-инцидента — restore из бэкапа + откат git revert.

Риск отката оценивается как **нулевой** — на проде webhook_log = 0, рудимент никем не используется.

### §4.4 Совместимость с другими спеками

- **Спек A (₽-баланс, Phase A на проде)** — этот спек снимает блокер для Phase B.
- **Спек B (дубли, на проде)** — не пересекается (Спек B про шеринг-канал; legacy webhook имеет собственный `webhook_dedup_keys`, который тоже удаляется).
- **Спек C (preflight + VTB)** — не пересекается (preflight работает на уровне `SupplierQuotaAllocator`; VTB — на пополнении баланса, не на списании).

---

## §5. Тестирование

### §5.1 Регрессионная проверка

- `composer test` — Pest --parallel, должно пройти на dev (после удаления тестов количество suite уменьшится).
- `npm run test:vue` — Vitest, должен остаться зелёным (UI не трогаем кроме возможного раздела webhook-token, если impact-check найдёт).
- Lefthook pre-commit — все джобы зелёные.
- Larastan — без новых ошибок (baseline регенерация только если потребуется).

### §5.2 Smoke-проверка на проде после деплоя

- `curl -X POST https://liderra.ru/api/webhook/test-token -d '{}'` → ожидается **404** (роут больше не существует).
- `curl -X POST https://liderra.ru/api/webhook/supplier/$SECRET -d '{...}'` → ожидается **200/202** (шеринг-канал работает как раньше).
- `SELECT * FROM information_schema.tables WHERE table_name IN ('webhook_log', 'webhook_dedup_keys')` → 0 строк (миграция применилась).
- `SELECT column_name FROM information_schema.columns WHERE table_name='tenants' AND column_name LIKE 'webhook_%'` → 0 строк (если impact-check подтвердил удаление колонок).

### §5.3 7-дневное наблюдение

После деплоя — наблюдать:

- `failed_jobs` (новые fail'ы только от шеринг-канала).
- nginx access log на `/api/webhook/{token}` 404 — если **кто-то** реально начнёт долбиться (что маловероятно: 0 вызовов за всю историю), но если начнёт — это сигнал к ретроспективе.
- Sentry-алерты — без новых регрессий (Sentry pending Б-1).

---

## §6. Выкатка

**Один PR, один релиз** (заказчик подтвердил 2026-05-24).

Шаги (детали — в плане реализации):

1. Изолированный worktree.
2. Impact-checks (§3) — финальный список «что удаляем точно, что оставляем».
3. Code-удаление + удаление/чистка тестов.
4. Миграция БД (одна идемпотентная миграция с DROP).
5. Полная регрессия (`composer test` + `npm run test:vue` + lefthook).
6. Subagent code-review.
7. Push в main, FF merge.
8. Деплой на боевой:
   - Бэкап `pg_dump` перед миграцией (runbook).
   - `git archive | scp | tar -xf` (10-15 файлов).
   - `redeploy.sh` (composer + migrate + cache + reload php-fpm).
   - Smoke-проверка (§5.2).
9. 7-дневное наблюдение (§5.3).

---

## §7. Связано

- [Спек A Биллинг v2 — единый ₽-баланс](2026-05-23-billing-v2-spec-a-balance-rub-design.md) (Phase B = блокирован этим документом до выпиливания).
- [Спек B Биллинг v2 — политика дублей](2026-05-23-billing-v2-spec-b-duplicates-design.md) (не пересекается, но даёт контекст шеринг-канала).
- [Спек C Биллинг v2 — preflight + VTB](2026-05-24-billing-v2-spec-c-preflight-vtb-design.md) (не пересекается).
- [Supplier integration spec](2026-05-10-supplier-integration-design.md) §5–§6 (определение шеринг-канала, который остаётся единственным боевым).
- [CSV reconcile channel spec](2026-05-18-supplier-csv-reconcile-channel-design.md) (резервный канал, не трогается).
- `docs/deploy/test-server-runbook.md` (бэкап перед миграцией).
- Памятки в коде комментариев: [routes/web.php:282](../../../app/routes/web.php#L282) уже маркирует роут как «legacy».
