# MVP Spec 16 — CPL Billing Infrastructure

## Паспорт документа

- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
  - [Product roadmap и delivery checklist](../product-roadmap.md)
  - [Roadmap](../../project/roadmap.md)
  - [Карта API-маршрутов](../../architecture/api-routes.md)

> Version: MVP · Priority: P1 · Phase: A (Supply)
> Status: Draft v1

---

## 1. Контекст и цель

CPL (Cost Per Lead) — основная модель монетизации Qadam в MVP. Платформа не берёт комиссию с транзакций и не продаёт размещение: Qadam зарабатывает $30 за каждый лид, доставленный продавцу. Лид считается доставленным в момент успешной подачи заявки покупателем (статус `new`).

**Цель модуля:** построить инфраструктуру, которая автоматически фиксирует каждый тарифицируемый лид, позволяет Admin генерировать инвойсы за произвольный период и вручную отмечать оплату. Продавец видит свою историю биллинга в личном кабинете.

**Ключевые факты:**
- Цена лида: **$30 (фиксированная, год 1)**
- Оплата: **банковский перевод** (не онлайн-платёж). Admin вручную отмечает "оплачено"
- Период выставления счёта: Admin выбирает произвольный диапазон дат
- Лид биллируется **один раз** в момент создания (статус `new`)
- Валюта инвойса: USD (отображается продавцу, может конвертироваться в UZS — TBD)

**Что не входит в этот модуль:**
- Приём онлайн-платежей → вне скоупа MVP
- Создание лидов → Spec 09
- Telegram-уведомления о лидах → Spec 10
- Email-рассылка инвойсов → TBD

---

## 2. Роли пользователей

| Роль | Действия в этом модуле |
|------|----------------------|
| **Root Admin** | Полный доступ: биллинг-дашборд, генерация инвойсов, отметка оплаты, просмотр всех инвойсов |
| **Marketer** | Только просмотр биллинг-дашборда и инвойсов (без генерации и отметки оплаты) |
| **Seller (Owner)** | Просмотр своей истории биллинга и своих инвойсов в /seller/billing |
| **Seller (Staff)** | Нет доступа к биллингу |

---

## 3. Use Cases

---

### UC-01: Лид подан — биллинговое событие фиксируется

**Актор:** Система (автоматически)
**Предусловие:** Покупатель успешно подал заявку на курс
**Триггер:** Lead успешно создан со статусом `new`

**Полный поток:**

```
[Точка входа]
→ Покупатель заполнил форму заявки на /item/[slug]
→ POST /api/leads (Spec 09) вернул 201 с lead_id, seller_id
→ Lead.status = 'new' записан в БД

───────────────────────────────────────────────────────
АВТОМАТИЧЕСКАЯ ЗАПИСЬ БИЛЛИНГОВОГО СОБЫТИЯ
───────────────────────────────────────────────────────
→ В той же транзакции что создаётся Lead:
    1. Создаётся LeadBillingEvent:
       - event_id: новый UUID
       - lead_id: только что созданный lead.lead_id
       - seller_id: lead.seller_id
       - amount_usd: 30.00 (из конфига CPL_PRICE_USD = 30)
       - currency: 'USD'
       - recorded_at: now()
    2. Если транзакция отвалилась — LeadBillingEvent не создаётся
       (Lead и LeadBillingEvent атомарны)

→ Никакого UI — это фоновая операция
→ Событие появится в биллинг-дашборде Admin при следующей загрузке
```

**Альтернативные потоки:**

**1a. Создание лида провалилось (валидация, дубликат):**
```
Поведение:
→ Lead не создан → LeadBillingEvent не создаётся
→ Транзакция откатывается полностью
→ Никакого "висячего" биллингового события
```

**1b. LeadBillingEvent не удалось записать (БД временно недоступна):**
```
Поведение:
→ Транзакция откатывается → Lead тоже не создаётся
→ Покупателю: ошибка "Не удалось подать заявку. Попробуйте снова."
→ Данные формы не сбрасываются
→ Операция повторяется по нажатию кнопки
```

---

### UC-02: Admin открывает биллинг-дашборд

**Актор:** Root Admin
**Предусловие:** Admin авторизован, существуют зафиксированные LeadBillingEvent
**Триггер:** Admin переходит в /admin/billing

**Полный поток:**

```
[Точка входа]
→ Admin в навигации /admin нажимает "Биллинг"
→ Открывается /admin/billing

───────────────────────────────────────────────────────
ДАШБОРД — ОБЗОР
───────────────────────────────────────────────────────
→ Панель фильтров вверху:
    - Дата с * (datepicker)
    - Дата по * (datepicker)
    - Продавец (поиск по имени/org_name, необязательно)
    - Кнопка "Применить фильтр"
→ По умолчанию: текущий календарный месяц (1 числа — сегодня)

→ Таблица: "Лиды по продавцам за период"
    Колонки:
    | Продавец | Org name | Лидов | Сумма ($) | Уже выставлено | Ещё не выставлено |
    | -------- | -------- | ----- | --------- | -------------- | ----------------- |
    Где:
    - "Лидов" = COUNT(LeadBillingEvent) за период
    - "Сумма ($)" = COUNT × $30
    - "Уже выставлено" = лидов, которые включены в InvoiceItem за этот период
    - "Ещё не выставлено" = лидов без инвойса за период

→ Строка "Итого" в конце таблицы

→ Кнопка "Сгенерировать инвойс" рядом с каждым продавцом
  (только если "Ещё не выставлено" > 0)

→ Вкладка "Инвойсы" переключает на список всех инвойсов (UC-03)
```

**Альтернативные потоки:**

**2a. За выбранный период нет лидов:**
```
UI-реакция:
→ Таблица пустая
→ Плашка: "За выбранный период лидов не найдено."
→ Кнопки "Сгенерировать инвойс" не отображаются
```

**2b. Фильтр: дата "по" раньше даты "с":**
```
UI-реакция:
→ Поле "Дата по": красная обводка + ⚠
→ Под полем: "Дата окончания должна быть позже даты начала."
→ Кнопка "Применить фильтр" заблокирована
```

**2c. Marketer открывает /admin/billing:**
```
Поведение:
→ Marketer видит дашборд и таблицу лидов (readonly)
→ Кнопки "Сгенерировать инвойс" и "Отметить оплату" — скрыты
→ Вкладка "Инвойсы" — доступна (только просмотр)
```

---

### UC-03: Admin генерирует инвойс для продавца

**Актор:** Root Admin
**Предусловие:** Есть незафактурированные лиды у продавца за период
**Триггер:** Admin нажимает "Сгенерировать инвойс" напротив нужного продавца

**Полный поток:**

```
[Точка входа]
→ Admin на /admin/billing видит продавца с > 0 "Ещё не выставлено" лидов
→ Нажимает "Сгенерировать инвойс"
→ Открывается модальное окно "Создать инвойс"

───────────────────────────────────────────────────────
ФОРМА — Создание инвойса
───────────────────────────────────────────────────────
→ Предзаполненные поля (readonly):
    - Продавец: {org_name} (seller_id)
    - Период: {date_from} — {date_to} (из фильтра дашборда)
    - Количество лидов: {N} (незафактурированных за период)
    - Сумма к оплате: ${N × 30}
→ Редактируемые поля:
    - Примечание для продавца (admin_note, необязательно, до 500 символов)
→ Кнопки: [Отмена] [Создать инвойс]
→ Admin нажимает "Создать инвойс"
→ Система:
    1. Создаёт SellerInvoice:
       - invoice_id: новый UUID
       - seller_id: из формы
       - period_start, period_end: из фильтра
       - leads_count: N
       - total_amount_usd: N × 30
       - status: pending_payment
       - admin_note: из формы (если заполнено)
    2. Создаёт InvoiceItem[] — связь каждого LeadBillingEvent с инвойсом
       (чтобы один лид не попал в два инвойса)
→ Модал закрывается
→ Toast: "Инвойс #{invoice_id} создан. Сумма: ${total}."
→ Строка продавца в дашборде: "Ещё не выставлено" → 0
```

**Альтернативные потоки:**

**3a. За период нет незафактурированных лидов (race condition — другой Admin создал инвойс раньше):**
```
UI-реакция:
→ При открытии модала: сервер возвращает leads_count = 0
→ Модал показывает: "Все лиды за этот период уже включены в инвойс."
→ Кнопка "Создать инвойс" заблокирована
→ Ссылка: "Посмотреть существующий инвойс"
```

**3b. Уже существует открытый (pending_payment) инвойс для этого продавца за пересекающийся период:**
```
UI-реакция:
→ В модале: жёлтый баннер "Предупреждение: для этого продавца уже есть инвойс
  за период {existing_period} со статусом «Ожидает оплаты». Убедитесь, что
  создаёте новый инвойс сознательно."
→ Кнопка "Создать инвойс" активна — Admin принимает решение сам
```

**3c. Технический сбой при создании инвойса:**
```
UI-реакция:
→ Toast (красный): "Не удалось создать инвойс. Попробуйте снова."
→ Модал не закрывается, данные не сбрасываются
→ Транзакция откатывается — частичного состояния нет
```

---

### UC-04: Admin отмечает инвойс как оплаченный

**Актор:** Root Admin
**Предусловие:** Инвойс существует со статусом `pending_payment` или `overdue`
**Триггер:** Admin получил подтверждение банковского перевода от продавца

**Полный поток:**

```
[Точка входа]
→ Admin на /admin/billing открывает вкладку "Инвойсы"
→ Видит таблицу инвойсов:
    Колонки: Номер | Продавец | Период | Лидов | Сумма | Статус | Создан | Действия

→ Находит нужный инвойс (фильтры: по продавцу, по статусу, по периоду)
→ В колонке "Действия" нажимает "Отметить оплату"
→ Открывается модальное окно "Подтвердить оплату"

───────────────────────────────────────────────────────
ФОРМА — Подтверждение оплаты
───────────────────────────────────────────────────────
→ Показывает:
    - Инвойс: #{invoice_id}
    - Продавец: {org_name}
    - Сумма: ${total_amount_usd}
    - Статус: pending_payment → paid
→ Поле: Примечание (admin_note, необязательно, до 500 символов)
  (например: "Оплата получена, платёжное поручение №12345")
→ Кнопки: [Отмена] [Подтвердить оплату]
→ Admin нажимает "Подтвердить оплату"
→ Система:
    - SellerInvoice.status = 'paid'
    - SellerInvoice.paid_at = now()
    - SellerInvoice.admin_note = примечание (если заполнено)
→ Модал закрывается
→ Toast: "Инвойс #{invoice_id} отмечен как оплаченный."
→ В таблице: статус меняется на "Оплачен" (зелёный бейдж)
```

**Альтернативные потоки:**

**4a. Admin пытается отметить уже оплаченный инвойс:**
```
Поведение:
→ Кнопка "Отметить оплату" не отображается для статуса paid
→ Вместо неё — текст "Оплачен {paid_at}" (зелёный)
→ Если запрос пришёл через API: 400 INVOICE_ALREADY_PAID
```

**4b. Admin хочет отметить инвойс как просроченный (overdue):**
```
[Точка входа]
→ В строке инвойса нажимает "..." → "Отметить просроченным"
→ Диалог: "Изменить статус инвойса #{id} на «Просрочен»?"
→ [Отмена] [Отметить просроченным]
→ SellerInvoice.status = 'overdue'
→ Toast: "Инвойс отмечен как просроченный."
→ В таблице: статус "Просрочен" (красный бейдж)

Переход из overdue в paid:
→ "Отметить оплату" доступна и для overdue-инвойсов
```

**4c. Попытка изменить статус оплаченного инвойса обратно:**
```
Поведение:
→ Статус paid — финальный. Кнопки для смены статуса скрыты.
→ Если через API: 400 INVALID_STATUS_TRANSITION
→ Response: { error: 'INVALID_STATUS_TRANSITION', message: 'Оплаченный инвойс нельзя изменить.' }
```

---

### UC-05: Продавец просматривает историю биллинга

**Актор:** Seller (Owner)
**Предусловие:** Продавец авторизован, существуют инвойсы или биллинговые события
**Триггер:** Продавец переходит в /seller/billing

**Полный поток:**

```
[Точка входа]
→ Продавец в левом меню личного кабинета нажимает "Биллинг"
→ Открывается /seller/billing

───────────────────────────────────────────────────────
СТРАНИЦА — История биллинга продавца
───────────────────────────────────────────────────────
→ Верхний блок — Статистика:
    ┌────────────────────────────────────────────────┐
    │  Всего лидов        Оплачено ($)   Ожидает ($) │
    │     {total}           {paid}        {pending}  │
    └────────────────────────────────────────────────┘

→ Раздел "Инвойсы":
    Таблица:
    | Период | Лидов | Сумма ($) | Статус | Создан | Действия |
    |--------|-------|-----------|--------|--------|---------|
    Статусы с бейджами:
    - pending_payment: жёлтый "Ожидает оплаты"
    - paid: зелёный "Оплачен"
    - overdue: красный "Просрочен"

→ Кнопка "Скачать PDF" рядом с каждым инвойсом (→ TBD)

→ Раздел "Лиды по периодам":
    Фильтр: месяц (month picker, default: текущий месяц)
    Таблица:
    | Дата | Курс | Имя заявителя | Статус лида | Биллинг |
    |------|------|---------------|-------------|---------|
    (Биллинг: зелёная галочка "Учтён" или серый "—")

→ Продавец видит только свои данные (seller_id из токена)
```

**Альтернативные потоки:**

**5a. Нет ни одного инвойса и нет лидов:**
```
UI-реакция:
→ Раздел "Инвойсы": плашка "Инвойсов пока нет. Они появятся после первых заявок."
→ Статистика: все нули
```

**5b. Продавец — Seller Staff (не Owner):**
```
Поведение:
→ GET /seller/billing → 403 FORBIDDEN
→ Редирект на /seller с Toast: "Раздел биллинга доступен только владельцу аккаунта."
```

**5c. Продавец пытается открыть инвойс другого продавца (по прямой ссылке):**
```
Поведение:
→ GET /api/seller/billing/invoices/{invoice_id} с чужим invoice_id
→ 404 INVOICE_NOT_FOUND (не 403, чтобы не раскрывать существование)
```

---

## 4. Бизнес-правила и валидации

### Таблица валидаций и бизнес-правил

| Правило | Описание | Ошибка / Поведение |
|---------|----------|--------------------|
| Цена лида | $30 фиксированно (год 1). Берётся из конфига `CPL_PRICE_USD` | Изменение через конфиг, не через UI |
| Биллинговое событие — атомарно с лидом | LeadBillingEvent создаётся в той же транзакции что и Lead | При сбое — оба откатываются |
| Один лид = один LeadBillingEvent | Уникальный индекс event на lead_id | 409 при попытке дублировать |
| **30-дневное окно дедупликации (обязательно)** | Если за последние 30 дней уже есть лид с тем же `buyer_account_id + item_id` (для авторизованных) или `lead_phone + item_id` (для гостей) — `LeadBillingEvent` **НЕ создаётся**. Лид создаётся в БД, но не тарифицируется. Источник правила: Spec 07. | `Lead.created = true`, `LeadBillingEvent.created = false`, ответ содержит `{ duplicate: true }` |
| Инвойс не перекрывает уже зафактурированные лиды | InvoiceItem.lead_billing_event_id → уникальный | Лид не может войти в два инвойса |
| Период инвойса | Admin задаёт произвольный диапазон дат (period_start, period_end) | period_end ≥ period_start |
| Статусная машина инвойса | pending_payment → paid (финальный); pending_payment → overdue → paid | Другие переходы запрещены (400) |
| Отмена инвойса | Не реализована в MVP | TBD: аннулирование через admin_note + ручная корректировка |
| Доступ продавца | Только к своим LeadBillingEvent и SellerInvoice (по seller_id из JWT) | 404 при попытке доступа к чужим |
| Валюта | USD. Конвертация в UZS — TBD | Отображение: $30.00 |
| Биллинг-дашборд: дефолтный период | Текущий календарный месяц (1-е числа — сегодня) | |

---

## 5. Модель данных

> Новые сущности этого модуля. Lead (Spec 09), Seller (Spec 01) — существующие.

### LeadBillingEvent

| Атрибут | Тип | Описание |
|---------|-----|---------|
| event_id | UUID | PK |
| lead_id | UUID FK | → Lead (unique — один лид = одно событие) |
| seller_id | UUID FK | → Seller (денормализовано для быстрой агрегации) |
| amount_usd | Decimal(10,2) | Default: 30.00 |
| currency | string | 'USD' (зарезервировано для будущих валют) |
| recorded_at | DateTime | Время фиксации (= время создания лида) |

### SellerInvoice

| Атрибут | Тип | Описание |
|---------|-----|---------|
| invoice_id | UUID | PK |
| seller_id | UUID FK | → Seller |
| period_start | DateTime | Начало периода (inclusive) |
| period_end | DateTime | Конец периода (inclusive) |
| leads_count | Int | Количество лидов в инвойсе |
| total_amount_usd | Decimal(10,2) | leads_count × 30.00 |
| status | InvoiceStatus | pending_payment / paid / overdue |
| paid_at | DateTime? | Заполняется при переходе в paid |
| admin_note | string? | Комментарий Admin (до 500 символов) |
| created_at | DateTime | |
| updated_at | DateTime | |

### InvoiceItem (связь инвойса с биллинговыми событиями)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| item_id | UUID | PK |
| invoice_id | UUID FK | → SellerInvoice |
| lead_billing_event_id | UUID FK | → LeadBillingEvent (unique — один в один инвойс) |
| @@unique([invoice_id, lead_billing_event_id]) | | |

---

## 6. Технические контракты

### 6.1 Prisma Schema

```prisma
enum InvoiceStatus {
  pending_payment
  paid
  overdue
}

model LeadBillingEvent {
  event_id    String   @id @default(uuid())
  lead_id     String   @unique
  seller_id   String
  amount_usd  Decimal  @default(30.00) @db.Decimal(10, 2)
  currency    String   @default("USD") @db.VarChar(3)
  recorded_at DateTime @default(now())

  lead         Lead          @relation(fields: [lead_id], references: [lead_id])
  seller       Seller        @relation(fields: [seller_id], references: [seller_id])
  invoice_items InvoiceItem[]

  @@index([seller_id, recorded_at])
}

model SellerInvoice {
  invoice_id       String        @id @default(uuid())
  seller_id        String
  period_start     DateTime
  period_end       DateTime
  leads_count      Int
  total_amount_usd Decimal       @db.Decimal(10, 2)
  status           InvoiceStatus @default(pending_payment)
  paid_at          DateTime?
  admin_note       String?       @db.VarChar(500)
  created_at       DateTime      @default(now())
  updated_at       DateTime      @updatedAt

  seller Seller        @relation(fields: [seller_id], references: [seller_id])
  items  InvoiceItem[]

  @@index([seller_id, status])
  @@index([status, period_start])
}

model InvoiceItem {
  item_id                String @id @default(uuid())
  invoice_id             String
  lead_billing_event_id  String @unique

  invoice             SellerInvoice    @relation(fields: [invoice_id], references: [invoice_id])
  lead_billing_event  LeadBillingEvent @relation(fields: [lead_billing_event_id], references: [event_id])

  @@unique([invoice_id, lead_billing_event_id])
}
```

### 6.2 TypeScript DTO

```typescript
// ─── Admin: Биллинг-дашборд ───────────────────────────────────────────────

export class BillingDashboardQueryDto {
  @IsISO8601()
  date_from: string  // ISO 8601

  @IsISO8601()
  date_to: string

  @IsOptional() @IsUUID()
  seller_id?: string
}

export interface BillingDashboardRowDto {
  seller_id: string
  org_name: string
  total_leads: number
  total_amount_usd: number
  invoiced_leads: number
  uninvoiced_leads: number
  uninvoiced_amount_usd: number
}

export interface BillingDashboardResponseDto {
  period_start: string
  period_end: string
  rows: BillingDashboardRowDto[]
  totals: {
    total_leads: number
    total_amount_usd: number
    uninvoiced_leads: number
    uninvoiced_amount_usd: number
  }
}

// ─── Admin: Создание инвойса ──────────────────────────────────────────────

export class CreateInvoiceDto {
  @IsUUID()
  seller_id: string

  @IsISO8601()
  period_start: string

  @IsISO8601()
  period_end: string

  @IsOptional() @IsString() @MaxLength(500)
  admin_note?: string
}

export interface InvoiceResponseDto {
  invoice_id: string
  seller_id: string
  org_name: string
  period_start: string
  period_end: string
  leads_count: number
  total_amount_usd: number
  status: InvoiceStatus
  paid_at: string | null
  admin_note: string | null
  created_at: string
}

// ─── Admin: Обновление статуса инвойса ────────────────────────────────────

export class UpdateInvoiceStatusDto {
  @IsEnum(['paid', 'overdue'])
  status: 'paid' | 'overdue'

  @IsOptional() @IsString() @MaxLength(500)
  admin_note?: string
}

// ─── Seller: История биллинга ─────────────────────────────────────────────

export interface SellerBillingStatsDto {
  total_leads: number
  total_billed_usd: number
  total_paid_usd: number
  total_pending_usd: number
}

export interface SellerBillingLeadRowDto {
  lead_id: string
  item_name: string
  buyer_name: string
  lead_status: string
  recorded_at: string
  billed: boolean
  invoice_id: string | null
}

export class SellerBillingLeadsQueryDto {
  @IsOptional() @IsString()
  month?: string  // 'YYYY-MM', default: текущий месяц
}
```

### 6.3 API Endpoints

```
────────────────────────────────────────────────────────────────
ADMIN: БИЛЛИНГ-ДАШБОРД
────────────────────────────────────────────────────────────────

GET /api/admin/billing/dashboard?date_from=&date_to=&seller_id=
Auth: Bearer (admin: root | marketer — readonly для marketer)
→ 200: BillingDashboardResponseDto
→ 400: { error: 'INVALID_DATE_RANGE', message: 'Дата окончания должна быть позже даты начала.' }

────────────────────────────────────────────────────────────────
ADMIN: ИНВОЙСЫ
────────────────────────────────────────────────────────────────

GET /api/admin/billing/invoices?seller_id=&status=&date_from=&date_to=
Auth: Bearer (admin: root | marketer — readonly для marketer)
→ 200: InvoiceResponseDto[]

GET /api/admin/billing/invoices/:invoice_id
Auth: Bearer (admin: root | marketer)
→ 200: InvoiceResponseDto & { items: LeadBillingEventShortDto[] }
→ 404: { error: 'INVOICE_NOT_FOUND' }

POST /api/admin/billing/invoices
Auth: Bearer (admin: root only)
Body: CreateInvoiceDto
→ 201: InvoiceResponseDto
→ 400: { error: 'INVALID_DATE_RANGE' | 'NO_UNINVOICED_LEADS', message: string }
→ 403: { error: 'FORBIDDEN' }  // если marketer попытался
→ 422: { errors: [{ field: string, message: string }] }

PATCH /api/admin/billing/invoices/:invoice_id/status
Auth: Bearer (admin: root only)
Body: UpdateInvoiceStatusDto
→ 200: InvoiceResponseDto
→ 400: { error: 'INVALID_STATUS_TRANSITION', message: 'Оплаченный инвойс нельзя изменить.' }
→ 400: { error: 'INVOICE_ALREADY_PAID', message: string }
→ 403: { error: 'FORBIDDEN' }
→ 404: { error: 'INVOICE_NOT_FOUND' }

────────────────────────────────────────────────────────────────
SELLER: ИСТОРИЯ БИЛЛИНГА
────────────────────────────────────────────────────────────────

GET /api/seller/billing/stats
Auth: Bearer (seller owner)
→ 200: SellerBillingStatsDto
→ 403: { error: 'FORBIDDEN' }  // если seller staff

GET /api/seller/billing/invoices
Auth: Bearer (seller owner)
→ 200: InvoiceResponseDto[]  // только свои

GET /api/seller/billing/invoices/:invoice_id
Auth: Bearer (seller owner)
→ 200: InvoiceResponseDto
→ 404: { error: 'INVOICE_NOT_FOUND' }  // не раскрываем чужие

GET /api/seller/billing/leads?month=YYYY-MM
Auth: Bearer (seller owner)
→ 200: SellerBillingLeadRowDto[]

────────────────────────────────────────────────────────────────
INTERNAL: HOOK (вызывается из Lead Service)
────────────────────────────────────────────────────────────────

// Не HTTP-endpoint — вызывается напрямую из LeadService внутри транзакции
// BillingService.recordLeadEvent(lead_id, seller_id, tx: PrismaTransaction)
// Возвращает LeadBillingEvent | throws (откатывает транзакцию целиком)
```

---

## 7. Edge Cases

| Сценарий | Поведение |
|----------|----------|
| Лид создан, LeadBillingEvent не создан из-за ошибки БД | Транзакция откатывается → лид тоже не создаётся. Атомарность гарантирована |
| Admin генерирует два инвойса одновременно для одного продавца и периода (race condition) | Уникальный индекс на InvoiceItem.lead_billing_event_id не позволяет включить один лид в два инвойса. Второй POST вернёт 400 NO_UNINVOICED_LEADS |
| Продавец удалён/заблокирован, но у него есть открытые инвойсы | Инвойсы остаются в БД со статусом pending_payment; Admin решает вручную |
| CPL_PRICE_USD изменяется (будущее) | Новые LeadBillingEvent создаются по новой цене. Старые не пересчитываются |
| Период инвойса охватывает 0 лидов (все уже зафактурированы) | 400 NO_UNINVOICED_LEADS — инвойс не создаётся |
| Продавец видит лид который ещё не включён в инвойс | В таблице лидов: billed = false, invoice_id = null |
| Admin нажимает "Отметить оплату" на invoice со статусом overdue | Разрешено: overdue → paid |
| Инвойс создан за период где лиды уже войдут в следующий период | Систем не блокирует — Admin сам определяет периоды. Ответственность Admin |
| period_start = period_end (один день) | Допустимо. Фактурируются лиды WHERE recorded_at >= period_start AND recorded_at <= period_end |

---

## 8. TBD / Сознательно опущено

| Тема | Статус | Примечание |
|------|--------|-----------|
| PDF-экспорт инвойса | TBD | Кнопка "Скачать PDF" присутствует в UI, но генерация не реализована. Заглушка → v1.0 |
| Email-рассылка инвойсов продавцам | Исключено из MVP | Admin уведомляет вручную (через Telegram или email). Автоотправка — v1.0 |
| Конвертация USD → UZS | TBD | Курс не определён. MVP — только USD. Показывать ли UZS-эквивалент — решение в v1.0 |
| Автоматическая смена статуса в overdue по дате | Исключено из MVP | Отсутствует CRON. Статус overdue — только ручная отметка Admin |
| Аннулирование/кредит-нот инвойса | Вне скоупа MVP | Если инвойс создан ошибочно — Admin добавляет admin_note и не включает в отчёт. Формальный механизм аннулирования — v1.0 |
| Агрегированный биллинг-отчёт (CSV-экспорт) | TBD | Нужен ли экспорт в Excel/CSV для бухгалтерии? Не реализован в MVP |
| Налоги (НДС, налог у источника) | Вне скоупа MVP | Юридический анализ налогообложения не завершён |
| Несколько валют | Вне скоупа MVP | Только USD. Мультивалютность — после v1.0 |
| Автоматическая генерация инвойсов по расписанию | Вне скоупа MVP | Всё ручное. Крон-задача для авто-генерации — v1.0 |
| Пагинация в /seller/billing/leads | TBD | В MVP — без пагинации (по месяцу, ограничен объём) |

---

## 9. Зависимости

| Модуль | Связь |
|--------|-------|
| **Spec 09** (Leads) | Lead.lead_id → LeadBillingEvent.lead_id; BillingService вызывается внутри LeadService при создании лида |
| **Spec 01** (Seller) | Seller.seller_id → LeadBillingEvent.seller_id, SellerInvoice.seller_id |
| **Spec 03** (Staff) | Seller Staff не имеет доступа к /seller/billing |
| **Spec 04** (Admin) | Root Admin — единственная роль с правом создания инвойсов и отметки оплаты |
| **Spec 10** (Notifications) | TBD: уведомление продавцу при создании инвойса — через Telegram/email |
| **PostgreSQL** | Транзакционная атомарность Lead + LeadBillingEvent |
