# Биллинг v2 — Спек B: политика дублей (дизайн)

**Дата:** 2026-05-23
**Статус:** дизайн утверждён заказчиком, готов к writing-plans
**Серия:** Биллинг v2 (Спек A — балансовая модель ✅ Phase A; Спек B — дубли; Спек C — preflight + VTB)
**Связано:** `docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md`, memory `project-billing-v2`

---

## 1. Главное правило (governing rule)

**Дедупликация лидов — ответственность ПОСТАВЩИКА (crm.bp-gr.ru), не Лидерры.**

Поставщик чистит повторы в своём окне (≈30 дней). Что поставщик прислал — для Лидерры это «настоящий» лид: мы его раздаём клиентам и берём за него деньги. **Лидерра повторы по телефону НЕ фильтрует.**

Единственная наша забота — **не наделать своих дублей**: одна поставка одному клиенту = ровно один оплаченный лид. При этом один лид по-прежнему можно продать **до 3 РАЗНЫХ клиентов** (модель шеринга — это норма, не дубль).

Формулировка заказчика (брейнсторм 23.05.2026):

- «убираем все фильтры! но главное нам самим не наделать дублей — нам прислал поставщик в одном экземпляре, а мы клиенту выдали 2 раза! это не касается правила: 1 лид может быть продан 3-м».
- «если 5 клиентов заказали лиды с одного источника, то мы можем продать лид только 3-м максимум».

«Смотри через это правило всё окружение» — спецификация проходит по всем местам, где есть логика дублей/дедупа, и приводит их в соответствие с правилом.

### Разрез понятий (важно не путать)

| Понятие | Что это | Решение |
|---|---|---|
| **Дубль по телефону** (антифрод) | Два разных лида с одинаковым телефоном | НЕ фильтруем — убираем (правило выше) |
| **Технический повтор поставки** | Тот же физический лид пришёл дважды (один `vid`) | Идемпотентность по `vid` — оставляем |
| **Наш собственный дубль** | Одна поставка → одному клиенту 2 копии | Запрещаем (новый замок в БД) |
| **Шеринг** | Один лид → до 3 РАЗНЫХ клиентов | Норма, сохраняем (лимит теперь по клиентам) |
| **CSV-восстановление повтора** | CSV тащит то, что уже принято вебхуком | Дедуп CSV по (телефон, проект) — оставляем |

---

## 2. Текущее состояние (as-is)

- **`app/app/Services/DuplicateDetector.php`** — телефонный фильтр. `findMaster(tenantId, phone, now)` ищет master-сделку `(tenant_id, phone)` с `received_at >= now − 24h` и `duplicate_of_id IS NULL`. `WINDOW_HOURS = 24`.
- Вызывается в двух местах:
  - **`ProcessWebhookJob`** (прямой вебхук, `WebhookReceiveController` → `ProcessWebhookJob`; legacy-путь на `balance_leads`): `handle()` находит master → `markAsDuplicate()` (проставляет `duplicate_of_id`, НЕ списывает). Идемпотентность по `vid` обеспечена `webhook_dedup_keys (tenant_id, source_crm_id)`.
  - **`RouteSupplierLeadJob`** (шеринг, `SupplierWebhookController` → `SupplierLead` → `RouteSupplierLeadJob`; новый путь на `LedgerService`): `createDealCopyForProject()` находит master → помечает дубль, без `chargeForDelivery`.
- **`LeadRouter::matchEligibleProjects`** — отдаёт ВСЕ подходящие проекты (по всем клиентам), среди них могут быть несколько проектов одного клиента.
- **`LeadDistributor::selectRecipients`** — `CAP = 3`, берёт 3 случайных **проекта** (не клиента). → один лид может попасть в 2+ проекта одного клиента = «наш дубль».
- **`CsvReconcileJob`** — дедуп CSV-строк по (телефон, проект) против уже принятых `supplier_leads` за окно 2 дня; недостающие → `SupplierLead(vid=NULL, source='csv_recovery')` → `RouteSupplierLeadJob`.
- **Схема:** `deals.duplicate_of_id BIGINT` (без FK — `deals` партиционирована), индекс `ON deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL`; индекс `(tenant_id, phone, received_at)`.
- **UI:** строка `duplicate_detected` в матрице уведомлений `SettingsView.vue` (8×3). Уведомление **нигде в коде не отправляется** (мёртвая строка). На карточке сделки/в списке отображения дублей нет.

---

## 3. Целевое состояние (to-be)

### 3.1. Убираем наш антифрод-фильтр

- Удаляем сервис `DuplicateDetector`.
- В `ProcessWebhookJob`: убираем вызов `findMaster` + метод `markAsDuplicate`; новая сделка всегда списывается через `chargeNewLead`. Защита от своих дублей здесь — существующая идемпотентность по `vid` (один `vid` → одна сделка на клиента; разные `vid` с одним телефоном → обе списываются — целевое поведение).
- В `RouteSupplierLeadJob::createDealCopyForProject`: убираем вызов `findMaster` + ветку пометки дубля.

### 3.2. Раздача по клиентам, а не по проектам

- Подбор получателей схлопывается **до одного проекта на клиента**: среди подходящих проектов клиента берём проект с **наибольшим остатком дневного лимита** (`COALESCE(effective_daily_limit_today, daily_limit_target) − delivered_today` по убыванию; при равенстве — `created_at, id` по возрастанию). Детерминированно.
- Лимит `CAP = 3` теперь применяется к **разным клиентам**. 5 клиентов под один источник → ровно 3 получают лид (случайный выбор среди клиентов через инъектируемый `Randomizer`, как сейчас).
- Реализация (на усмотрение writing-plans): `DISTINCT ON (tenant_id)` в `LeadRouter` с соответствующим `ORDER BY`, либо явное схлопывание по `tenant_id` в отдельном шаге; `LeadDistributor::selectRecipients` остаётся cap=3 поверх уже «один-на-клиента» списка.

### 3.3. Замок в БД — «одна поставка одному клиенту = один раз» (ядро Варианта B)

Новая таблица-замок `supplier_lead_deliveries`:

| Колонка | Тип | Назначение |
|---|---|---|
| `supplier_lead_id` | BIGINT NOT NULL | поставка (FK на `supplier_leads(id)` ON DELETE CASCADE — `supplier_leads` не партиционирована) |
| `tenant_id` | BIGINT NOT NULL | клиент |
| `deal_id` | BIGINT NULL | созданная сделка (без FK — `deals` партиционирована, паттерн как `duplicate_of_id`) |
| `created_at` | TIMESTAMPTZ NOT NULL DEFAULT now() | |

- **PRIMARY KEY (`supplier_lead_id`, `tenant_id`)** — уникальность «поставка ↔ клиент».
- **RLS** `tenant_isolation` по `tenant_id` (USING + WITH CHECK на `app.current_tenant_id`), как у прочих tenant-таблиц.
- GRANT'ы для 5 ролей (`crm_app_user`, `crm_app_admin`, `crm_supplier_worker` BYPASSRLS, `crm_readonly`, `crm_migrator`).

**Логика в `createDealCopyForProject`** (внутри той же транзакции с `SET LOCAL app.current_tenant_id`, ПОСЛЕ lock'а tenant и recheck'а лимита проекта, ДО создания сделки):

1. `INSERT INTO supplier_lead_deliveries (supplier_lead_id, tenant_id, created_at) VALUES (...) ON CONFLICT (supplier_lead_id, tenant_id) DO NOTHING`.
2. Если вставлено 0 строк → эта поставка этому клиенту уже выдавалась → `return false` (сделку не создаём, баланс не списываем).
3. Иначе → создаём `Deal`, `UPDATE supplier_lead_deliveries SET deal_id = ...`, затем `LedgerService::chargeForDelivery` + счётчики + уведомление.

**Почему ключ по `supplier_lead_id`, а не по телефону:** это НЕ возвращает телефонный фильтр. Два разных лида с одним телефоном — две разные поставки, два разных `supplier_lead_id`, обе оплачиваются. А одну и ту же поставку клиенту дважды БД физически не пропустит — даже при гонках, перезапусках задачи (`tries=3`) и CSV-восстановлении (где `vid=NULL`, но `supplier_lead_id` всегда есть).

**Scope:** замок нужен только в шеринг-пути (`RouteSupplierLeadJob`). Прямой вебхук (`ProcessWebhookJob`) не идёт через `supplier_leads` и уже защищён `webhook_dedup_keys (tenant_id, vid)` — там замок не требуется.

### 3.4. Чистка следов концепции дублей

- Перестаём писать `deals.duplicate_of_id`. Колонку оставляем **спящей** (решение заказчика 23.05 — безопаснее; удалить можно отдельной задачей, как `balance_leads` в Спеке A Phase B). Индекс `ON deals (duplicate_of_id) WHERE NOT NULL` становится лишним — удаляем сразу.
- Убираем строку `duplicate_detected` из матрицы уведомлений `SettingsView.vue` (8×3 → 7×3). Старый ключ в сохранённых `users.notification_preferences` JSONB просто игнорируем (не ломает).

### 3.5. Что НЕ трогаем

- Идемпотентность по `vid` в `ProcessWebhookJob` (`webhook_dedup_keys`).
- Дедуп CSV по (телефон, проект) в `CsvReconcileJob` — это защита от наших дублей, в духе правила.
- Рабочие дни (`delivery_days_mask`), дневные лимиты, проверку баланса (eligibility) — настройки клиента/биллинг, не фильтры дублей.

---

## 4. Крайние случаи

| Случай | Поведение |
|---|---|
| Один телефон у РАЗНЫХ клиентов | Каждый клиент оплачивает свою копию (без изменений — корректно) |
| Один телефон, ДВЕ разные поставки (`vid` A и B), один клиент | Обе списываются (НОВОЕ — раньше глушилось телефонным фильтром) |
| Одна поставка, у клиента 2+ подходящих проекта | Один проект (max остаток лимита), одно списание (замок + раздача-по-клиентам) |
| 5 клиентов под один источник | Ровно 3 получают и оплачивают |
| CSV-восстановленный лид (`vid=NULL`) | Замок по `supplier_lead_id` работает; повторная выдача тому же клиенту не пройдёт |
| Перезапуск задачи (`tries=3`) после частичного успеха | `processed_at` + замок не дают повторных списаний |
| Прямой вебхук, тот же `vid` повторно | Одна сделка (идемпотентность `vid`), без изменений |

---

## 5. Тесты

Добавить:

- Один телефон, две разные поставки, один клиент → списано дважды.
- Одна поставка, у клиента 2 подходящих проекта → одна сделка + одно списание; выбран проект с наибольшим остатком лимита (тай-брейк).
- 5 клиентов eligible под один источник → ровно 3 списания у 3 разных клиентов.
- Замок: повторный `INSERT` той же `(supplier_lead_id, tenant_id)` → `DO NOTHING`, сделка не создаётся, баланс не тронут.
- CSV-восстановление: лид с `vid=NULL`, повторная выдача клиенту → замок срабатывает.

Удалить:

- Тесты телефонного фильтра в `ProcessWebhookJobTest`, `RouteSupplierLeadJobTest`, `RouteSupplierLeadJobBillingTest`, `SupplierLeadFlowTest`, `AutoPauseFlowTest`, `DealCreatePdLogTest` (по факту наличия — verify в writing-plans).

Регрессия: Pest на затронутом коде зелёный; Larastan/Pint/ESLint clean; Vitest на `SettingsView` (после правки матрицы).

---

## 6. Выкатка

- Изменение **одна-фазное**: код + новая аддитивная таблица `supplier_lead_deliveries`. Двухфазность (как в Спеке A с DROP COLUMN) не нужна — ничего разрушающего (`duplicate_of_id` остаётся в БД спящей).
- `db/CHANGELOG_schema.md` — запись о новой таблице (правило проекта §4.2).
- **Бизнес-эффект:** после выката клиенты начнут платить за то, что раньше глушилось как «дубль» — это и есть заказанная правка. Предупредить заказчика перед прод-merge.

---

## 7. Границы (out of scope)

- `DuplicateDetector.WINDOW_HOURS` тюнинг — N/A, сервис удаляется целиком.
- Балансовая модель, тарифные ступени — Спек A.
- Preflight баланса, `SupplierQuotaAllocator`, VTB-эквайринг — Спек C.
- Физическое удаление `deals.duplicate_of_id` — потенциальная отдельная cleanup-задача.

---

## 8. Ключевые решения брейнсторма (зафиксированы)

1. Дедуп — ответственность поставщика; Лидерра телефон не фильтрует.
2. `DuplicateDetector` удаляется полностью (Вариант B принят над «лёгким» A и отвергнутым C «помечать но списывать»).
3. Лимит шеринга «3» = 3 РАЗНЫХ клиента; одному клиенту одна копия.
4. Тай-брейк проекта внутри клиента — максимальный остаток дневного лимита (детерминированно).
5. Защита от своих дублей — на уровне БД (замок `supplier_lead_deliveries`, ключ по поставке, не по телефону).
6. `duplicate_of_id` остаётся спящей; индекс по ней удаляется.
7. Одна-фазная выкатка.
