Qadam Roadmap
проектdocs/Agents/specs/2026-03-24-mvp-spec-16-cpl-billing.md

MVP Spec 16 — CPL Billing Infrastructure

Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев

MVP Spec 16 — CPL Billing Infrastructure

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

  • Статус документа: working spec
  • Актуально на: 28 марта 2026 года
  • Владелец: backend/platform-команда
  • Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
  • Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
  • Связанные документы:

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_id409 при попытке дублировать
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)
Отмена инвойсаНе реализована в MVPTBD: аннулирование через admin_note + ручная корректировка
Доступ продавцаТолько к своим LeadBillingEvent и SellerInvoice (по seller_id из JWT)404 при попытке доступа к чужим
ВалютаUSD. Конвертация в UZS — TBDОтображение: $30.00
Биллинг-дашборд: дефолтный периодТекущий календарный месяц (1-е числа — сегодня)

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

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

LeadBillingEvent

АтрибутТипОписание
event_idUUIDPK
lead_idUUID FK→ Lead (unique — один лид = одно событие)
seller_idUUID FK→ Seller (денормализовано для быстрой агрегации)
amount_usdDecimal(10,2)Default: 30.00
currencystring'USD' (зарезервировано для будущих валют)
recorded_atDateTimeВремя фиксации (= время создания лида)

SellerInvoice

АтрибутТипОписание
invoice_idUUIDPK
seller_idUUID FK→ Seller
period_startDateTimeНачало периода (inclusive)
period_endDateTimeКонец периода (inclusive)
leads_countIntКоличество лидов в инвойсе
total_amount_usdDecimal(10,2)leads_count × 30.00
statusInvoiceStatuspending_payment / paid / overdue
paid_atDateTime?Заполняется при переходе в paid
admin_notestring?Комментарий Admin (до 500 символов)
created_atDateTime
updated_atDateTime

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

АтрибутТипОписание
item_idUUIDPK
invoice_idUUID FK→ SellerInvoice
lead_billing_event_idUUID FK→ LeadBillingEvent (unique — один в один инвойс)
@@unique([invoice_id, lead_billing_event_id])

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

6.1 Prisma Schema

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

// ─── 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-рассылка инвойсов продавцамИсключено из MVPAdmin уведомляет вручную (через Telegram или email). Автоотправка — v1.0
Конвертация USD → UZSTBDКурс не определён. 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/leadsTBDВ 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