# Phase 2: Idempotent dedup webhook ↔ CSV-recovered

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Webhook, поступивший после CSV-recovered deal по `(tenant_id, phone, project_id)` в окне 24h, **обновляет** существующий deal (`source_crm_id`, `received_at`), не создаёт второй. Без двойного списания биллингом. Закрывает 37 дублей сутки.

**Architecture:** В `RouteSupplierLeadJob::createDealCopyForProject` под уже существующей `DB::transaction + lockForUpdate(Tenant)+lockForUpdate(Project)` добавляется проверка «есть ли csv-recovered deal по `(tenant_id, phone, project_id, received_at ≥ now()-24h, source_crm_id IS NULL)`». Если есть — `UPDATE existing.source_crm_id = lead.vid` + `INSERT supplier_lead_deliveries` (привязка webhook к existing deal), **БЕЗ** `chargeForDelivery`. Возврат специального статуса `MERGED` (не считается в `$createdCount`, не failure).

**Tech Stack:** Laravel 13 / Pest 4 / PHP 8.3 / PostgreSQL 16 / bcmath / RLS

**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 2
**Предусловие:** Phase 1 deployed и наблюдаем clean ≥30 мин.
**Ветка:** `feat/supplier-webhook-fixes` (продолжение)

---

## Открытый вопрос (OQ-1 из спеки) — резолвится в Task 1

`LedgerService::chargeForDelivery` (app/app/Services/Billing/LedgerService.php:47-117) — **НЕ идемпотентен**: каждый вызов делает INSERT LeadCharge, BalanceTransaction, supplier_lead_costs + decrement balance_rub. Поэтому критично НЕ вызывать его второй раз для merged deal.

---

## File Structure

**Создать:**
- `app/tests/Feature/Supplier/CsvWebhookRaceTest.php` — TDD-тесты для merge сценария

**Изменить:**
- `app/app/Jobs/RouteSupplierLeadJob.php` — добавить блок поиска csv-recovered deal в `createDealCopyForProject`

**Не трогать:**
- `LedgerService.php` — не меняем, идемпотентность достигается через ранний return ДО его вызова
- `supplier_lead_deliveries` schema — не меняем (текущая `(supplier_lead_id, tenant_id)` UNIQUE остаётся; добавляем дополнительный row для merge case)
- `CsvReconcileJob.php` — не меняем (он создаёт SupplierLead с vid=NULL, как и было)

---

## Task 1: Verify LedgerService is NOT idempotent (read-only confirmation)

**Files:**
- Read: `app/app/Services/Billing/LedgerService.php`

- [ ] **Step 1: Confirm there is NO check for existing lead_charges with same deal_id**

Открыть [app/app/Services/Billing/LedgerService.php:47-117](../../../app/app/Services/Billing/LedgerService.php#L47-L117). Подтвердить:
- Нет `LeadCharge::where('deal_id', $deal->id)->exists()` guard.
- Нет SELECT перед INSERT.
- Метод просто делает INSERT, increment, INSERT, INSERT.

Если идемпотентность ЕСТЬ — пересмотреть план Phase 2 (может быть проще, без MERGED статуса). Если НЕТ (ожидаемо) — продолжаем по плану.

- [ ] **Step 2: Document in commit message**

Зафиксировать наблюдение в первом коммите Task 2. Никакой правки в LedgerService не делаем — guard добавляется в caller (RouteSupplierLeadJob).

---

## Task 2: Failing test — webhook after CSV-recovered merges, doesn't duplicate or double-charge

**Files:**
- Create: `app/tests/Feature/Supplier/CsvWebhookRaceTest.php`

- [ ] **Step 1: Write failing tests**

```php
<?php

declare(strict_types=1);

use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

/**
 * Phase 2 — webhook ↔ CSV-recovered idempotency.
 *
 * Сценарий (наблюдался на prod 2026-05-25):
 *   1. Поставщик шлёт webhook → 302 (теряется тело) — Phase 1 уже починила.
 *   2. CsvReconcileJob через 30 мин видит лид в CSV, не находит supplier_lead
 *      по (phone, project) → создаёт recovered SupplierLead (vid=NULL,
 *      source='csv_recovery') → RouteSupplierLeadJob → Deal с source_crm_id=NULL.
 *   3. Поставщик ретраит webhook (ещё 15 мин) → новый SupplierLead с vid=<int>
 *      → RouteSupplierLeadJob → создаёт второй Deal с тем же phone+project
 *      → биллинг списывает второй раз.
 *
 * Phase 2 fix: шаг 3 находит существующий CSV-recovered deal, обновляет
 * source_crm_id, привязывает webhook supplier_lead к существующему deal через
 * supplier_lead_deliveries, НЕ создаёт второй Deal, НЕ списывает повторно.
 */

beforeEach(function () {
    $this->tenant = Tenant::factory()->create([
        'balance_rub' => '1000.00',
        'delivered_in_month' => 0,
    ]);
    $this->project = Project::factory()->create([
        'tenant_id' => $this->tenant->id,
        'signal_type' => 'site',
        'signal_identifier' => 'krk-finance.ru',
        'is_active' => true,
        'daily_limit_target' => 100,
        'delivered_today' => 0,
    ]);
    // ... настроить supplier_projects + project_supplier_links для платформы B1
    //     identifier krk-finance.ru — детали зависят от фабрик
});

it('webhook after CSV-recovered merges into existing deal (no duplicate, no double-charge)', function () {
    // Step 1: simulate CSV-recovered SupplierLead (vid=null)
    $csvLead = SupplierLead::create([
        'platform' => 'B1',
        'phone' => '79991234567',
        'vid' => null,
        'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
        'received_at' => now()->subHour(),
        'recovered_from_csv_at' => now()->subHour(),
        'source' => 'csv_recovery',
    ]);
    (new RouteSupplierLeadJob($csvLead->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\NotificationService::class),
        app(\App\Services\Billing\LedgerService::class),
        app(\App\Services\LeadDistributor::class),
        app(\App\Services\RegionTagResolver::class),
    );

    $csvDeal = Deal::where('phone', '79991234567')->first();
    expect($csvDeal)->not->toBeNull();
    expect($csvDeal->source_crm_id)->toBeNull();
    $chargesAfterCsv = LeadCharge::where('deal_id', $csvDeal->id)->count();
    expect($chargesAfterCsv)->toBe(1); // одна charge от CSV-recovered

    $balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub;

    // Step 2: simulate webhook arriving 15 min later with real vid
    $webhookLead = SupplierLead::create([
        'platform' => 'B1',
        'phone' => '79991234567',
        'vid' => 1672819986,
        'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
        'received_at' => now()->subMinutes(15),
        'source' => 'webhook',
    ]);
    (new RouteSupplierLeadJob($webhookLead->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\NotificationService::class),
        app(\App\Services\Billing\LedgerService::class),
        app(\App\Services\LeadDistributor::class),
        app(\App\Services\RegionTagResolver::class),
    );

    // Assertion 1: still ONE deal, but source_crm_id теперь заполнен
    $deals = Deal::where('phone', '79991234567')->get();
    expect($deals)->toHaveCount(1);
    expect($deals->first()->source_crm_id)->toBe(1672819986);

    // Assertion 2: НЕТ второго LeadCharge (idempotency биллинга)
    $chargesAfterWebhook = LeadCharge::where('deal_id', $csvDeal->id)->count();
    expect($chargesAfterWebhook)->toBe(1); // всё ещё ОДИН charge

    // Assertion 3: balance НЕ списан второй раз
    $balanceAfterWebhook = (string) $this->tenant->fresh()->balance_rub;
    expect($balanceAfterWebhook)->toBe($balanceAfterCsv);

    // Assertion 4: supplier_lead_deliveries содержит ОБА supplier_lead_id,
    //   привязанные к ОДНОМУ deal.id
    $deliveries = DB::table('supplier_lead_deliveries')
        ->where('deal_id', $csvDeal->id)
        ->get();
    expect($deliveries)->toHaveCount(2);
    expect($deliveries->pluck('supplier_lead_id')->all())
        ->toContain($csvLead->id, $webhookLead->id);
});

it('two webhooks with DIFFERENT vids both create deals (Spec B — за повторы поставщика берём)', function () {
    // Регрессионный тест: если поставщик намеренно шлёт два webhook'а с РАЗНЫМИ
    // vid'ами на тот же phone+project — это два разных лида, оба должны быть
    // приняты. Спек B Phase 1 (commit ccfecd5e) специально снял DD для этого
    // кейса. Наш Phase 2 fix НЕ должен этому препятствовать.
    $lead1 = SupplierLead::create([
        'platform' => 'B1', 'phone' => '79991234567', 'vid' => 100,
        'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
        'received_at' => now()->subHour(), 'source' => 'webhook',
    ]);
    (new RouteSupplierLeadJob($lead1->id))->handle(/* ... */);

    $lead2 = SupplierLead::create([
        'platform' => 'B1', 'phone' => '79991234567', 'vid' => 200,
        'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
        'received_at' => now()->subMinutes(30), 'source' => 'webhook',
    ]);
    (new RouteSupplierLeadJob($lead2->id))->handle(/* ... */);

    // Assertion: ОБА webhook'а имеют source_crm_id (не NULL), поэтому merge
    // не происходит — это два разных лида у поставщика, два разных deal.
    $deals = Deal::where('phone', '79991234567')->get();
    expect($deals)->toHaveCount(2);
    expect($deals->pluck('source_crm_id')->all())->toContain(100, 200);
    expect(LeadCharge::whereIn('deal_id', $deals->pluck('id'))->count())->toBe(2);
});

it('csv-recovered deal older than 24h is NOT merged with new webhook', function () {
    // Окно merge — 24h. Если CSV-recovered deal старше — не считается duplicate.
    $csvLead = SupplierLead::create([
        'platform' => 'B1', 'phone' => '79991234567', 'vid' => null,
        'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => now()->subDays(2)->getTimestamp()],
        'received_at' => now()->subDays(2),
        'recovered_from_csv_at' => now()->subDays(2),
        'source' => 'csv_recovery',
    ]);
    (new RouteSupplierLeadJob($csvLead->id))->handle(/* ... */);

    $webhookLead = SupplierLead::create([
        'platform' => 'B1', 'phone' => '79991234567', 'vid' => 999,
        'raw_payload' => ['project' => 'B1_krk-finance.ru', 'phone' => '79991234567', 'time' => time()],
        'received_at' => now(), 'source' => 'webhook',
    ]);
    (new RouteSupplierLeadJob($webhookLead->id))->handle(/* ... */);

    // Assertion: TWO deals (старый CSV-recovered + новый webhook), не merge
    $deals = Deal::where('phone', '79991234567')->get();
    expect($deals)->toHaveCount(2);
});
```

NB: код тестов написан как **набросок**. При имплементации:
- Заменить `(new RouteSupplierLeadJob(...))->handle(/* ... */)` на правильную диспатч-схему (Bus::dispatchSync или вручную с DI). Посмотреть в [app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php](../../../app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php) для примера.
- Настроить supplier_projects + project_supplier_links фабрики правильно. Посмотреть в существующих тестах.

- [ ] **Step 2: Run tests, expect FAIL**

```
cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvWebhookRaceTest.php
```

Expected: тест #1 FAIL (deals.count == 2 а не 1; charges.count == 2 а не 1). Это подтверждает баг.

- [ ] **Step 3: Commit failing tests**

```bash
git add app/tests/Feature/Supplier/CsvWebhookRaceTest.php
git commit -m "test(supplier): assert webhook-after-csv-recovered merges into existing deal (failing)

Reproduces 37 duplicate deals observed on prod 2026-05-25 for tenant client1.
After Spec B Phase 1 (commit ccfecd5e) removed DuplicateDetector, the race
between CsvReconcileJob (creates SupplierLead vid=null) and later webhook
retry (vid=int) results in two separate Deals because supplier_lead_deliveries
locks on supplier_lead_id (which differs between csv-recovery and webhook),
not on (phone, project_id).

Failing now — implementation comes in next commit."
```

---

## Task 3: Implement merge logic in RouteSupplierLeadJob::createDealCopyForProject

**Files:**
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php:207-330`

- [ ] **Step 1: Add early merge check ДО supplier_lead_deliveries insertOrIgnore**

В `createDealCopyForProject`, **после** `$lockedProject = ... lockForUpdate(); ... if (delivered_today >= limit) return false;`, **до** `$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore(...)`:

```php
// Phase 2 fix: merge с CSV-recovered deal если webhook догоняет.
// Идемпотентность race condition между CsvReconcileJob (vid=NULL, recovered
// from CSV) и webhook (vid=int, реальный supplier-id). До этой проверки они
// создавали 2 deal'a (DD снят Spec B Phase 1). Merge выполняется только если:
//   - webhook ЕСТЬ настоящий vid (lead.vid !== null) — без vid merge'ить нечего;
//   - csv-recovered deal существует за последние 24h, тот же phone+project+tenant;
//   - csv-recovered deal БЕЗ source_crm_id (т.е. он именно CSV-recovered, не другой webhook).
// При merge: UPDATE existing.source_crm_id, INSERT supplier_lead_deliveries,
// БЕЗ chargeForDelivery (LeadCharge уже есть с момента CSV recovery).
$existingMergeable = null;
if ($lead->vid !== null) {
    $existingMergeable = Deal::query()
        ->where('tenant_id', $tenant->id)
        ->where('phone', (string) $lead->phone)
        ->where('project_id', $project->id)
        ->whereNull('source_crm_id')
        ->where('received_at', '>=', now()->subDay())
        ->lockForUpdate()
        ->first();
}
if ($existingMergeable !== null) {
    // Заполняем supplier_lead.id у обоих SupplierLead → одному Deal
    DB::table('supplier_lead_deliveries')->insert([
        'supplier_lead_id' => $lead->id,
        'tenant_id' => $tenant->id,
        'deal_id' => $existingMergeable->id,
        'created_at' => now(),
    ]);
    $existingMergeable->source_crm_id = $lead->vid;
    if ($lead->received_at !== null && $lead->received_at->gt($existingMergeable->received_at)) {
        $existingMergeable->received_at = $lead->received_at;
    }
    $existingMergeable->save();

    Log::info('supplier_lead.merged_into_csv_recovered', [
        'supplier_lead_id' => $lead->id,
        'merged_into_deal_id' => $existingMergeable->id,
        'tenant_id' => $tenant->id,
    ]);

    return true; // считаем «доставленным», но без второго списания
}

// Spec B: per-(supplier_lead, tenant) lock — existing code ниже без изменений
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
    // ... existing ...
]);
```

NB:
- `lockForUpdate()` на existingMergeable защищает от двойного merge при параллельных queue workers.
- Условие `whereNull('source_crm_id')` — критично: оно отличает CSV-recovered (vid=NULL → source_crm_id=NULL) от настоящих webhook deals (source_crm_id=vid). Без этого условия мы бы мерджили на любой повтор поставщика, что **сломало бы Spec B**.
- Insert в `supplier_lead_deliveries` — простой `->insert()`, не `->insertOrIgnore()`. Потому что `(supplier_lead_id, tenant_id)` уникален, и для webhook-after-csv это новая комбинация (другой supplier_lead_id чем у csv-recovered).

- [ ] **Step 2: Run tests, expect PASS**

```
cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvWebhookRaceTest.php
```

Expected: все 3 теста PASS.

- [ ] **Step 3: Run full supplier test suite (regression)**

```
cd app && ./vendor/bin/pest tests/Feature/Supplier/ tests/Feature/Jobs/RouteSupplierLeadJobTest.php
```

Expected: все existing тесты PASS. Особенно:
- `SupplierLeadDeliveryGuardTest` (текущий lock-механизм)
- `RouteSupplierLeadJobBillingTest` (биллинг)
- `RouteSupplierLeadJobTest`
- `CsvReconcileJobTest`

Если что-то сломалось — это знак что existingMergeable условие слишком широкое. Сузить и повторить.

- [ ] **Step 4: Commit implementation**

```bash
git add app/app/Jobs/RouteSupplierLeadJob.php
git commit -m "fix(supplier): merge webhook into csv-recovered deal, no double-charge

Adds early merge check in RouteSupplierLeadJob::createDealCopyForProject:
when lead.vid IS NOT NULL and an existing deal with NULL source_crm_id
exists for (tenant, phone, project_id) within last 24h, UPDATE that
deal's source_crm_id instead of creating a second Deal. INSERT into
supplier_lead_deliveries links the new supplier_lead.id to the existing
deal.id. LedgerService::chargeForDelivery is NOT called — the original
charge happened when the csv-recovery created the deal.

Closes 37 duplicate deals observed on prod for tenant client1 25.05.2026.
Spec B Phase 1 (commit ccfecd5e) removed DuplicateDetector — this fix
restores idempotency for the specific webhook-after-csv-recovered case
WITHOUT re-blocking intentional supplier repeats with different vids.

Guard: only merges where source_crm_id IS NULL (the CSV-recovered marker).
Two webhooks with different vids on same phone+project still create two
deals — by-design per Spec B."
```

---

## Task 4: Regression and prod data probe

**Files:**
- None

- [ ] **Step 1: /regression full**

```
/regression full
```

Expected: GREEN. Особенно фокус на Pest --parallel (race conditions).

- [ ] **Step 2: Prod data probe — current state of duplicates**

ДО деплоя:
```bash
ssh liderra "sudo -u postgres psql -d liderra -P pager=off -c \"SELECT phone, project_id, COUNT(*) AS cnt FROM deals WHERE tenant_id=2 AND created_at::date = CURRENT_DATE GROUP BY phone, project_id HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10\""
```

Зафиксировать список (это будут текущие 37 пар). После деплоя — повторить ту же команду через 2 часа: новые пары не должны появляться.

---

## Task 5: Deploy to liderra.ru

**Files:**
- None

- [ ] **Step 1: prod-deploy-validator agent**

```
subagent_type: prod-deploy-validator
prompt: проверь готовность боевого liderra.ru к Phase 2 деплою. Меняется только RouteSupplierLeadJob.php (добавлен merge-check для CSV-recovered deals). Миграций БД нет. Очередь — queue:restart обязателен, потому что job изменился. Phase 1 уже на проде ≥30 мин.
```

- [ ] **Step 2: Merge to main + push**

```bash
git checkout main
git merge --ff-only feat/supplier-webhook-fixes
git push origin main
```

- [ ] **Step 3: redeploy on prod**

```bash
ssh liderra "cd /var/www/liderra/app && sudo -u www-data ./redeploy.sh 2>&1 | tail -50"
```

Expected: успешно. Особенно проверить что `php artisan queue:restart` отработал (см. в выводе redeploy.sh).

- [ ] **Step 4: Prod smoke — нет новых дублей за 2 часа**

Подождать 2 часа, потом:
```bash
ssh liderra "sudo -u postgres psql -d liderra -P pager=off -c \"SELECT phone, project_id, COUNT(*) FROM deals WHERE tenant_id=2 AND created_at >= NOW() - interval '2 hours' GROUP BY phone, project_id HAVING COUNT(*) > 1\""
```

Expected: **0 rows** (нет новых дублей за 2 часа после деплоя).

- [ ] **Step 5: Check merge logs**

```bash
ssh liderra "sudo grep 'merged_into_csv_recovered' /var/www/liderra/app/storage/logs/laravel.log | tail -20"
```

Expected: есть записи (показывает что merge сработал). Каждая запись — закрытый дубль.

- [ ] **Step 6: Update ПИЛОТ.md + memory**

```bash
# Edit ПИЛОТ.md mentioning Phase 2 deployed + merge stats
git add ПИЛОТ.md
git commit -m "docs(пилот): Phase 2 supplier dedup deployed, $N merges in 2h window"
git push origin main
```

---

## Done criteria для Phase 2

- [ ] Все тесты в `CsvWebhookRaceTest.php` PASS
- [ ] Все существующие `tests/Feature/Supplier/` PASS (regression)
- [ ] /regression full GREEN
- [ ] За 2 часа после деплоя — 0 новых пар дубликатов на проде
- [ ] Существуют `merged_into_csv_recovered` записи в логе (показывает что merge работает)
- [ ] Phase 3 plan starts only after Phase 2 observed clean ≥2h

---

## Откат

```bash
ssh liderra "cd /var/www/liderra/app && git revert --no-edit HEAD && sudo -u www-data ./redeploy.sh 2>&1 | tail -20"
```

Миграций нет → откат мгновенный. Дубли начнут возникать снова, но эти 2-3 часа потерь покрываются CsvReconcileJob.
