# Supplier webhook reliability — design spec

**Дата:** 2026-05-25
**Статус:** draft → готов к плану
**Ветка:** `feat/supplier-webhook-fixes`
**Связано:** Спек B Phase 1 (`docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md`) — снят DuplicateDetector; данная спека закрывает race condition, оставшийся после Спека B.

---

## 1. Проблема

На боевом liderra.ru за сутки 25.05.2026 для тенанта `client1` (tenant_id=2):

- Поставщик crm.bp-gr.ru отдал **205 уникальных лидов** (учётка `info@lkomega.ru`, страница `/admin/visit/index-visit?visit=rt`)
- На портале — **160 сделок**, из них **123 уникальных телефона** (37 — дубликаты `phone+project`)
- **Расхождения:** 82 лида у поставщика не дошли до портала; 37 deals в портале дублированы

### 1.1. Корневая причина потерь (76 из 82)

Из 234 POST-запросов поставщика на `/api/webhook/supplier/<secret>` сегодня:
- **132** → 202 Accepted (приняты)
- **76** → 302 Found (Location: `https://liderra.ru`)
- 29 → 301 (http→https на `/`)

Воспроизведено вручную: `curl -X POST` с пустым `{}` → 302 + Set-Cookie. Это **дефолтный Laravel behavior**: для запросов, где `Accept` НЕ содержит `application/json`, `ValidationException` рендерится через `redirect()->back()->withErrors()` — 302 на referer (которого нет у webhook-вызывающего) → fallback на `/`.

Запросы 302 — это webhook-и где `project` НЕ матчится regex `'project' => regex:/^B[123]_.+$/'` ([app/app/Http/Controllers/Api/SupplierWebhookController.php:86](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L86)).

Конкретные «непринимаемые» проекты (видны в supplier rt-list):
- `client.carmoney.ru` — 55 лидов
- `B2_Caranga` — 7
- `cabinet.caranga.ru` — 3
- `cashmotor.ru` — 2
- остальные единичные: `73912346386`, `79135191264`, `78006009393`, `78007006600`, `79029248888`, `B2_drivezaim`, `B3_+7 (495) 023-66-52` и т.п.

### 1.2. Корневая причина дублей (37)

[app/app/Jobs/Supplier/CsvReconcileJob.php:146-155](../../../app/app/Jobs/Supplier/CsvReconcileJob.php#L146-L155) каждые 30 мин создаёт «recovered» `SupplierLead` с **`vid: null`**, `source: csv_recovery` для лидов, найденных в CSV поставщика но отсутствующих в наших `supplier_leads` за окно.

Затем поставщик ретраит webhook с настоящим `vid` (численный) → создаётся **новый** `SupplierLead` (UNIQUE по `vid`, NULL ≠ NULL → не считается дублем) → `RouteSupplierLeadJob` создаёт **второй Deal**.

`supplier_lead_deliveries` уник-индекс на `(supplier_lead_id, tenant_id)` ([app/app/Jobs/RouteSupplierLeadJob.php:249-262](../../../app/app/Jobs/RouteSupplierLeadJob.php#L249-L262)) **не блокирует**, потому что у CSV-recovered и webhook разные `supplier_lead.id`.

Раньше эту race-condition закрывал `DuplicateDetector` (24h-фильтр по `phone+project`), который был снят в Спеке B Phase 1 (commit `ccfecd5e`, 24.05) с обоснованием «за повторы поставщика берём».

### 1.3. Цепочка B-префикса (5 точек)

Regex `B[123]_` встречается в коде в **5 точках**, и все обязательны для текущего flow:

| # | Место | file:line | Поведение без B-префикса |
|---|---|---|---|
| 1 | Webhook validation | [SupplierWebhookController.php:86](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L86) | ValidationException → 302 (см. 1.1) |
| 2 | parsePlatform fallback | [SupplierWebhookController.php:183-188](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L183-L188) | silent fallback 'B1' |
| 3 | parseProjectField | [RouteSupplierLeadJob.php:172-200](../../../app/app/Jobs/RouteSupplierLeadJob.php#L172-L200) | **RuntimeException** → retry 3x → failed_webhook_jobs |
| 4 | extractPlatform | [CsvReconcileJob.php:237-244](../../../app/app/Jobs/Supplier/CsvReconcileJob.php#L237-L244) | возвращает `null` → строка в `unparseable_count` (56 сегодня) |
| 5 | БД constraint | `supplier_projects.platform CHECK IN (B1,B2,B3)` | нельзя сохранить platform=`DIRECT` |

---

## 2. Цели и не-цели

### Цели

- **C1.** Webhook на `/api/webhook/supplier/*` ВСЕГДА отвечает JSON (202/200/422/429/404), никогда не редиректит. Любая `ValidationException` для этого URL — JSON 422 с полем `errors`.
- **C2.** Webhook, поступивший после CSV-recovered deal по тому же `(tenant_id, phone, project_id)` в окне 24h, **обновляет** существующий deal (`source_crm_id`, `received_at` если новее, `phones`), а не создаёт второй. Биллинг не списывает второй раз.
- **C3.** Webhook на проекты без префикса `B[123]_` (`client.carmoney.ru`, `cashmotor.ru`, числовые) принимается, проходит routing, создаёт Deal под новой платформой `DIRECT`.

### Не-цели

- **NG1.** Восстановление 82 потерянных лидов 25.05 — оффлайн-операция после деплоя, через `php artisan supplier:reconcile-force` или ручное добавление по списку (вне scope этой спеки).
- **NG2.** Очистка 37 текущих дублей в проде — отдельная миграция данных или ручной SQL (вне scope).
- **NG3.** Изменение бизнес-правил биллинга для DIRECT-платформы. Берётся та же тарификация, что для B1/B2/B3 (по умолчанию tier по `signal_type`). Альтернативная цена для DIRECT — отдельный спек если потребуется.
- **NG4.** Отказ от CSV reconcile job — он остаётся как safety net, но теперь дедупликация не приводит к дублям.

---

## 3. Решение

Три независимые фазы. Каждая фаза — отдельный PR, отдельный план, отдельный выкат на боевой. Между фазами — observation period (1-2 часа на проде, потом следующая фаза).

### Phase 1 (низкий риск) — Always JSON 422 для webhook validation errors

**Изменения:**

- В [app/bootstrap/app.php:35](../../../app/bootstrap/app.php#L35) `withExceptions()` добавить render:
  ```php
  $exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
      if ($request->is('api/webhook/supplier/*')) {
          return response()->json([
              'message' => 'Validation failed',
              'errors' => $e->errors(),
          ], 422);
      }
      return null; // дефолтный рендер для остальных
  });
  ```
- Тест: POST с `Accept: text/html` (имитация поставщика без JSON-Accept) на webhook с невалидным payload → assert 422 + JSON Content-Type + ошибка в `errors`.
- Существующие тесты `SupplierWebhookTest.php` — все `postJson(...)` → 422 уже работают. Добавляется один новый тест с обычным `post()`.

**Risk:** низкий. Изменение не трогает control flow webhook'а, только формат ответа на ошибку.

**Откатываемость:** одной строчкой revert.

### Phase 2 (средний риск) — Идемпотентность webhook ↔ CSV-recovered

**Изменения:**

- В [app/app/Jobs/RouteSupplierLeadJob.php:207](../../../app/app/Jobs/RouteSupplierLeadJob.php#L207) `createDealCopyForProject()` ДО создания Deal — поиск:
  ```php
  $existingDeal = Deal::query()
      ->where('tenant_id', $tenant->id)
      ->where('phone', (string) $lead->phone)
      ->where('project_id', $project->id)
      ->where('received_at', '>=', now()->subDay())
      ->whereNull('source_crm_id') // только CSV-recovered ждут vid
      ->lockForUpdate()
      ->first();
  ```
- Если найден → `UPDATE deals SET source_crm_id = vid, received_at = MAX(...)` + `supplier_lead_deliveries` запись + **НЕ списываем баланс повторно** (Ledger.alreadyChargedForDeal или просто отсутствие второго `chargeForDelivery`) → возврат `false`/`'merged'`.
- Если не найден → текущий путь создания нового Deal без изменений.
- `supplier_lead_deliveries.deal_id` обновляется на найденный deal.id.

**Биллинг safety:**
- `LedgerService::chargeForDelivery` уже идемпотентен по `supplier_lead_id` (PK lead_charges) — проверить.
- Если не идемпотентен — добавить guard: SELECT lead_charges WHERE deal_id=$existingDeal->id; если есть — skip charge.

**Тесты:**
- TDD: CSV-recovered deal без vid → webhook на тот же phone+project → assert 1 deal (не 2), source_crm_id заполнен, lead_charges = 1 запись.
- Regression: повтор поставщика по тому же vid (память Спека B — «за повторы берём») → assert 2 deals (если разные supplier_lead с разными vid).
- Race: одновременный webhook и CSV-recovery → lockForUpdate гарантирует один deal.

**Risk:** средний — затрагивает биллинг. Нужно убедиться что `chargeForDelivery` не списывает второй раз.

### Phase 3 (высокий риск) — DIRECT platform для проектов без B-префикса

**Изменения:**

1. **Миграция БД** `database/migrations/2026_05_25_120000_add_direct_platform.php`:
   ```sql
   ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform;
   ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform
     CHECK (platform IN ('B1','B2','B3','DIRECT'));
   ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform;
   ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform
     CHECK (platform IN ('B1','B2','B3','DIRECT'));
   ```
   Также снять constraint `chk_supplier_projects_b1_not_for_sms` (он про B1+sms) если он мешает.

2. **Webhook regex** [SupplierWebhookController.php:86](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L86):
   ```php
   'project' => ['required', 'string', 'max:255'], // снят regex
   ```

3. **parsePlatform** [SupplierWebhookController.php:183-188](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L183-L188):
   ```php
   private function parsePlatform(string $project): string
   {
       if (preg_match('/^(B[123])_/', $project, $m) === 1) {
           return $m[1];
       }
       return 'DIRECT';
   }
   ```

4. **parseProjectField** [RouteSupplierLeadJob.php:172-200](../../../app/app/Jobs/RouteSupplierLeadJob.php#L172-L200) — добавить DIRECT branch:
   ```php
   private function parseProjectField(string $project): array
   {
       if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) {
           $platform = $m[1];
           $rest = $m[2];
       } else {
           $platform = 'DIRECT';
           $rest = $project; // весь project считается identifier-частью
       }
       // далее существующая логика определения signal_type/identifier на $rest
       // (call / site / sms по тем же regex'ам)
   }
   ```

5. **extractPlatform** [CsvReconcileJob.php:237-244](../../../app/app/Jobs/Supplier/CsvReconcileJob.php#L237-L244):
   ```php
   private function extractPlatform(string $project): string
   {
       if (preg_match('/^(B[123])_/', $project, $m) === 1) {
           return $m[1];
       }
       return 'DIRECT';
   }
   ```
   Логика `unparseable_count` снимается для DIRECT-кейса; остаётся только для **реального мусора** (телефоны/URL в поле project). Различение через дополнительный regex проверки `[a-z0-9]` в начале.

6. **SupplierProjectResolver** — резолв по `(platform=DIRECT, signal_type, identifier)` создаёт/находит `supplier_projects` row с platform=DIRECT.

7. **LeadRouter::matchEligibleProjects** — DIRECT-platform fetches по тем же signal_type/identifier-полям проекта; никаких B1/B2/B3 специальных условий.

**Тесты:**
- Существующий тест `'rejects invalid project format with 422'` ([SupplierWebhookTest.php:95](../../../app/tests/Feature/Http/Webhook/SupplierWebhookTest.php#L95)) переписать: теперь invalid_format → 202 (принят), platform=DIRECT.
- Новый тест: webhook с `project: "client.carmoney.ru"` → 202, supplier_lead.platform=DIRECT, RouteSupplierLeadJob создаёт SupplierProject под DIRECT, Deal создаётся.
- Существующие тесты RouteSupplierLeadJobTest / CsvReconcileJobTest — добавить DIRECT-кейсы.
- Регрессия: все B1/B2/B3 кейсы продолжают работать без изменений.

**Risk:** высокий — затрагивает миграцию БД, ⩾5 файлов кода, тесты, бизнес-семантику биллинга для DIRECT.

**Сложность:** одновременная правка должна быть атомарной — если деплоится миграция но не код, controller примет lid'ы которые job не сможет обработать. Один PR, один деплой, очередь queue:restart после.

---

## 4. Стратегия деплоя

Три отдельных деплоя на liderra.ru через `redeploy.sh` (per memory: «`sudo -u www-data php artisan optimize` в строке 9 скрипта»):

1. **Деплой 1 (Phase 1):** ~10 мин outage риск 0. Сразу после деплоя смотрим nginx logs — все POST → 422 или 202, нет 30x. Ждём 30 мин — drift_alert не должен подниматься.
2. **Деплой 2 (Phase 2):** ~10 мин outage риск 0. Смотрим что новые deals не дублируются (`SELECT phone, project_id, COUNT(*) FROM deals WHERE created_at > NOW()-interval'2h' GROUP BY 1,2 HAVING COUNT(*)>1`). Ждём 1-2 часа.
3. **Деплой 3 (Phase 3):** включает миграцию БД. Сначала миграция (idempotent CHECK extension), затем код. Smoke: POST `project: "client.carmoney.ru"` с правильным secret и IP → 202, supplier_lead создан, deal создан. Ждём 6 часов на наблюдение, после — закрытие задачи.

Перед каждым деплоем — обязательно агент `prod-deploy-validator` (per [Pravila §2.4](../../Pravila_raboty_Claude_v1_1.md)).

---

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

### Pest unit/feature

Все три фазы — TDD: тест → fail → имплементация → pass → commit. Запуск `composer test -- --filter='Supplier'` после каждой фазы.

Существующие тесты, которые гарантированно адаптируются:
- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` — line 95 «invalid_format → 422» переписывается на «invalid_format → 202 DIRECT» в Phase 3.
- `app/tests/Feature/Supplier/CsvReconcileJobTest.php` — добавить кейс DIRECT в Phase 3.
- `app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php` — добавить «webhook после CSV-recovered не списывает второй раз» в Phase 2.
- `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` — добавить кейс «разные SupplierLead.id, тот же phone+project — не дубль» в Phase 2.

### Регрессия

`/regression full` ПОСЛЕ каждой фазы (Pest --parallel + Larastan + Vitest + Vite build + lychee + gitleaks). Каждая фаза — отдельный коммит на ветке `feat/supplier-webhook-fixes`, отдельный PR, отдельный merge → отдельный redeploy.

### Прод-smoke

После каждого деплоя — конкретные SQL-проверки в `db/`, описаны в каждом плане.

---

## 6. Откат

- Phase 1 — revert single commit.
- Phase 2 — revert commit + dedup кода. Миграции БД нет.
- Phase 3 — revert commit + миграция down: `DROP CONSTRAINT ... ADD CONSTRAINT ... CHECK IN (B1,B2,B3)`. Если в БД уже есть `platform=DIRECT` rows — миграция down упадёт. Нужен seed-cleanup перед откатом.

---

## 7. Файлы (общий список)

**Создать:**
- `database/migrations/2026_05_25_120000_add_direct_platform.php` (Phase 3)
- `app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php` (Phase 1, новый файл)
- `app/tests/Feature/Supplier/CsvWebhookRaceTest.php` (Phase 2, новый файл)
- `app/tests/Feature/Supplier/DirectPlatformTest.php` (Phase 3, новый файл)

**Изменить:**
- `app/bootstrap/app.php` (Phase 1)
- `app/app/Http/Controllers/Api/SupplierWebhookController.php` (Phase 3)
- `app/app/Jobs/RouteSupplierLeadJob.php` (Phase 2 + Phase 3)
- `app/app/Jobs/Supplier/CsvReconcileJob.php` (Phase 3)
- `app/app/Services/SupplierProjects/SupplierProjectResolver.php` (Phase 3)
- `app/app/Services/LeadRouter.php` (Phase 3)
- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (Phase 3 — переписать line 95)
- `db/schema.sql` (Phase 3 — sync с миграцией)
- `db/CHANGELOG_schema.md` (Phase 3)

**Возможно затронуть:**
- `app/app/Services/Billing/LedgerService.php` (Phase 2 — guard от двойного списания, если ещё не идемпотентен)

---

## 8. Открытые вопросы (на момент написания спеки)

- **OQ-1.** Идемпотентен ли `LedgerService::chargeForDelivery` по `(deal_id, lead_id)` или может списать дважды? — выяснится в Phase 2 Task 1 (read code).
- **OQ-2.** `supplier_projects.subject_code` — обязательное поле для DIRECT? — выяснится в Phase 3 Task 2 (миграция).
- **OQ-3.** `chk_supplier_projects_b1_not_for_sms` constraint конфликтует с DIRECT? — выяснится в Phase 3 Task 1.

Каждый вопрос разрешается inline во время реализации, не блокирует план.

---

## 9. Ссылки

- План Phase 1: `docs/superpowers/plans/2026-05-25-supplier-webhook-phase-1-json-422.md`
- План Phase 2: `docs/superpowers/plans/2026-05-25-supplier-webhook-phase-2-dedup.md`
- План Phase 3: `docs/superpowers/plans/2026-05-25-supplier-webhook-phase-3-direct-platform.md`
- Memory project_supplier_integration.md — историческая информация о supplier flow
- ADR-008 (если потребуется DIRECT — оформить как ADR-018 «Supplier DIRECT platform»)
