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_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
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-рассылка инвойсов продавцам | Исключено из 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 |