Brainstorming output для Plan 4: активация ступенчатого биллинга
(pricing_tiers/lead_charges никем не читались/писались), резервный CSV-канал
приёма лидов из портала поставщика, Admin UI (pricing-tiers editor +
supplier-prices editor + tenant ChargesTab).
9 разделов: контекст + 8 бизнес-инвариантов + 9 out-of-scope; schema delta
v8.18 → v8.19 (+1 таблица supplier_csv_reconcile_log, +3 колонки
tenants.delivered_in_month/lead_charges.charge_source/supplier_leads.recovered_from_csv_at,
+3 индекса, +2 CHECK); billing flow в RouteSupplierLeadJob (3 новых сервиса
PricingTierResolver/LedgerService/InsufficientBalanceException + dual-balance
prepaid-first логика + bcmath денежная арифметика); monthly reset
(ResetMonthlyCountersCommand + Schedule monthlyOn(1,'00:00') Europe/Moscow);
auto-pause flow (project.is_active=false + email с rate-limit 1/час/tenant);
CSV reconcile (расширение SupplierPortalClient + SupplierCsvParser +
CsvReconcileJob hourly + drift > 5% → email); Admin UI (2 SaaS-admin
view + 1 tab в существующем BillingView); тестовая стратегия (+71 теста)
и 10 AC; ограничения (6 неверифицированных).
Закрывает TODO «Биллинг per Plan 4» в RouteSupplierLeadJob.php:48.
Inherits from Plans 1+2+2.5+2.6+3 (HEAD origin/main = 926fee9).
Self-review applied 5 fixes inline: AC ссылки §3.5 → §7.1; auto-pause UPDATE
через pgsql_supplier BYPASSRLS connection; supplier_id source для
supplier_lead_costs INSERT; bccomp(bcmul()) вместо PHP float compare;
GRANT-policy в db/02_grants.sql, не schema.sql.
7 открытых вопросов с дефолтами в §7.6 для последующего ввода в реестр.
+5 терминов в cspell-words.txt (декрементится / Инкрементится / Подписочный
/ bcdiv / TRUNC) для прохождения lefthook cspell stage.
Парный план (writing-plans output) будет в docs/superpowers/plans/ отдельным
коммитом после согласования spec'а.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
42 KiB
Plan 4 (Billing + CSV Reconcile + Admin) Implementation Design
Дата: 2026-05-11
Статус: черновик дизайна (brainstorming output)
Заказчик: Дмитрий (владелец Лидерры)
Parent spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5.2, §7
Inherits from: Plan 1 (Foundation 001d781) + Plan 2 (Webhook+Routing d5aa972) + Plan 2.5 (concurrency hotfix c1ae195+1ba1df8) + Plan 2.6 (cleanup 7899071) + Plan 3 (Supplier Sync 734b0ab).
1. Контекст, инварианты, что уже есть в коде
1.1. Цель Plan 4
Активировать ступенчатый биллинг клиентов Лидерры (pricing_tiers / lead_charges), закрыть TODO «Биллинг per Plan 4» в RouteSupplierLeadJob.php:48, реализовать резервный CSV-канал приёма лидов и Admin UI для конфигурации.
1.2. Что уже есть в коде (после Plans 1+2+3)
| Сущность | Где | Состояние |
|---|---|---|
pricing_tiers (7 ступеней, копейки, effective_from) |
db/schema.sql:962 | Таблица создана (v8.14), никем не читается |
lead_charges (append-only ledger, RLS, FK на partitioned deals) |
db/schema.sql:1001 | Таблица создана (v8.15), никто не пишет |
tenants.balance_rub (DECIMAL 12,2 без CHECK), tenants.balance_leads (INT) |
db/schema.sql:629 | Существуют; balance_leads декрементится в RouteSupplierLeadJob |
projects.delivered_in_month (INTEGER) |
db/schema.sql:786 | Инкрементится в RouteJob, нет cron'а сброса |
BalanceTransaction (type=lead_charge, amount_leads=-1) |
RouteSupplierLeadJob:271 | Пишется при доставке |
SupplierLeadCost (per-deal закупочный snapshot, partitioned) |
db/schema.sql:2201, ProcessWebhookJob:216 | Пишется только в старом ProcessWebhookJob, в новом RouteSupplierLeadJob НЕ пишется (gap) |
suppliers.cost_rub (per-platform B1/B2/B3) |
db/schema.sql:352 | Seed B1/B2/B3 в schema.sql:2505 |
SupplierPortalClient + RefreshSupplierSessionJob + PlaywrightBridge |
app/app/Services/Supplier/*, Plan 3 |
Готово, переиспользуется для CSV |
SupplierCriticalAlertMail (email через Unisender Go) |
app/app/Mail/*, Plan 3 |
Готово, расширим алертами биллинга и drift'а |
1.3. Бизнес-инварианты Plan 4
- Dual balance: при доставке лида сначала пытаемся списать 1 единицу
balance_leads(prepaid pool); если 0 — списываем изbalance_rubпо текущей tier-цене. - Tier-lookup per-tenant: ступень определяется накопительной суммой
tenants.delivered_in_monthза месяц (новая колонка — см. §2). - Атомарность: charge + deal-INSERT + counter increment — одна транзакция.
lead_chargesпишется всегда при успешной доставке:price_per_lead_kopecks=0для prepaid, фактическая tier-цена для рублёвого списания.charge_source ENUM('prepaid','rub')для прозрачности. - Pause-on-insufficient: если оба источника пусты — лид НЕ доставляется этому проекту, статус Лидерра-проекта →
is_active=false+ email уведомление с rate-limit 1/час/tenant. - Pricing tier change → effective 1-го числа след. месяца: UI принимает изменения в любой день, но
effective_fromвсегда auto-set =DATE_TRUNC('month', NOW() + INTERVAL '1 month'). Текущая активная ступень =MAX(effective_from) WHERE effective_from <= CURRENT_DATE AND is_active=true. SupplierLeadCostдля sharing-flow: при каждой созданной deal-копии пишемsupplier_lead_costs(deal_id, supplier_id, cost_rub=suppliers.cost_rub)— закрывает существующий gap (Plan 2/3 не писали).- CSV reconcile: hourly. Сравнение по
supplier_leads.vidза окно 25h. Drift > 5% → email админу. Recovered лиды идут через тот жеRouteSupplierLeadJobсrecovered_from_csv_atмаркером. - Admin UI: SaaS-level
/admin/pricing-tiers(CRUD 7 ступеней) +/admin/supplier-prices(B1/B2/B3 cost_rub editor) + tenant-level «Списания» tab в существующемBillingView.
1.4. Out of scope Plan 4
- Balance top-up UI для admin/tenant — Plan 4.5 или Plan 5.
- Auto-resume project при пополнении баланса — Plan 4.5+.
- Подписочный биллинг / payments / счета-фактуры — Plan 5+.
- Per-tenant pricing override — out of MVP (spec §1.2).
- Replay сделок при изменении tier'а задним числом — нет (
effective_from+ append-onlylead_charges). Schedule::onOneServer()для новых cron'ов — требуетcache_locksтаблицу, gated на Б-1 / Managed PG в Yandex Cloud.- SaaS-admin auth middleware — gated на Б-1 + SSO заказчика; на MVP все
/api/admin/*без middleware (паритет с Plans 1/2/3). - Discovery CSV-схемы через credentials поставщика (паритет с Plan 3 Tasks 1+2 BLOCKED).
- Polling балансов из платёжного шлюза.
2. Schema delta v8.18 → v8.19
2.1. Изменения в существующих таблицах
tenants — +1 колонка delivered_in_month
ALTER TABLE tenants ADD COLUMN delivered_in_month INTEGER NOT NULL DEFAULT 0
CHECK (delivered_in_month >= 0);
COMMENT ON COLUMN tenants.delivered_in_month IS
'Накопительный счётчик доставленных лидов в текущем календарном месяце (Europe/Moscow). '
'Используется PricingTierResolver для определения текущей ступени pricing_tiers '
'на горячем пути RouteSupplierLeadJob. Сбрасывается в 0 cron-командой '
'ResetMonthlyCountersCommand 1-го числа в 00:00 МСК (Plan 4).';
Обоснование: SUM-by-projects на каждом charge — N+1 + lockForUpdate-race; денормализованный счётчик читается O(1) и обновляется в той же транзакции, что lead_charges INSERT.
tenants.balance_rub — CHECK НЕ добавляем
Намеренно: проверка < price происходит в LedgerService::canCharge(), в БД оставляем свободу для chargeback writedown (Ю-3, может уйти в плюс) и админских корректировок.
lead_charges — +1 колонка charge_source + 1 CHECK
ALTER TABLE lead_charges
ADD COLUMN charge_source VARCHAR(8) NOT NULL DEFAULT 'rub'
CHECK (charge_source IN ('prepaid','rub'));
ALTER TABLE lead_charges
ADD CONSTRAINT chk_lead_charges_prepaid_zero_price
CHECK (charge_source = 'rub' OR price_per_lead_kopecks = 0);
COMMENT ON COLUMN lead_charges.charge_source IS
'Источник списания: prepaid (balance_leads -1, price_per_lead_kopecks=0) '
'или rub (balance_rub минус price). Plan 4.';
DEFAULT 'rub' безопасен: до Plan 4 строк в таблице нет.
supplier_leads — +1 колонка recovered_from_csv_at
ALTER TABLE supplier_leads
ADD COLUMN recovered_from_csv_at TIMESTAMPTZ;
CREATE INDEX supplier_leads_recovered_from_csv_partial
ON supplier_leads(recovered_from_csv_at)
WHERE recovered_from_csv_at IS NOT NULL;
COMMENT ON COLUMN supplier_leads.recovered_from_csv_at IS
'NULL для лидов, пришедших через webhook (основной канал). Заполняется CsvReconcileJob '
'при восстановлении лида, который webhook пропустил. Plan 4.';
2.2. Новая таблица: supplier_csv_reconcile_log
CREATE TABLE supplier_csv_reconcile_log (
id BIGSERIAL PRIMARY KEY,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ,
window_start TIMESTAMPTZ NOT NULL,
window_end TIMESTAMPTZ NOT NULL,
total_csv_rows INTEGER,
matched_count INTEGER,
recovered_count INTEGER,
drift_ratio NUMERIC(5,4),
status VARCHAR(16) NOT NULL DEFAULT 'running'
CHECK (status IN ('running','ok','drift_alert','failed')),
error_message TEXT,
alert_email_sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX supplier_csv_reconcile_log_started_at_index
ON supplier_csv_reconcile_log(started_at DESC);
CREATE INDEX supplier_csv_reconcile_log_status_index
ON supplier_csv_reconcile_log(status)
WHERE status IN ('drift_alert','failed');
SaaS-level (не tenant-scoped), без RLS. Аналог supplier_sync_log. GRANT-policy: REVOKE/GRANT для crm_supplier_worker (BYPASSRLS) не идут в schema.sql — schema.sql остаётся DDL-only под dev (postgres superuser). GRANT SELECT,INSERT,UPDATE на supplier_csv_reconcile_log для crm_supplier_worker добавляется в db/02_grants.sql (паттерн Plan 2.6 #iv 7899071). На dev таблица доступна суперпользователю по умолчанию.
2.3. Seed-данные (отдельно от schema.sql)
7 ступеней pricing_tiers + системные настройки. НЕ идут в schema.sql (тот — DDL only), а в:
database/seeders/PricingTierSeeder.php— 7 строк сeffective_from='1970-01-01'.system_settingsrow seed в schema.sql §INSERTS — TBD ключsupplier_lead_price_default_kopecks(если потребуется fallback).
Дефолтные tier-цены ([?] для заказчика — открытый вопрос #1):
| tier_no | leads_in_tier | price_per_lead (руб) | price_per_lead_kopecks |
|---|---|---|---|
| 1 | 100 | 500.00 | 50000 |
| 2 | 200 | 450.00 | 45000 |
| 3 | 400 | 400.00 | 40000 |
| 4 | 800 | 350.00 | 35000 |
| 5 | 1500 | 300.00 | 30000 |
| 6 | 3000 | 270.00 | 27000 |
| 7 | NULL | 250.00 | 25000 |
2.4. Метрики после Plan 4
| Метрика | До (v8.18) | После (v8.19) | Δ |
|---|---|---|---|
| Базовые таблицы | 61 | 62 (+supplier_csv_reconcile_log) |
+1 |
| Партиции | 12 | 12 | 0 |
| Индексы | 114 | 117 | +3 |
| RLS-политики | 39 | 39 | 0 |
| CHECK constraints | (без явного подсчёта) | +2 (tenants.delivered_in_month >= 0, chk_lead_charges_prepaid_zero_price) |
+2 |
| Добавлено колонок | — | tenants.delivered_in_month, lead_charges.charge_source, supplier_leads.recovered_from_csv_at |
+3 |
2.5. Patch order в schema.sql
tenants.delivered_in_month— после строки 644 (desired_daily_numbers), в секции «Биллинг».lead_charges.charge_source+ CHECK — внутриCREATE TABLE lead_chargesпосле строки 1008.supplier_csv_reconcile_log— новая секция послеsupplier_sync_log.supplier_leads.recovered_from_csv_at— внутриCREATE TABLE supplier_leads(v8.18).- Bump version header v8.18 → v8.19 + entry в
db/CHANGELOG_schema.md.
3. Billing flow в RouteSupplierLeadJob
3.1. Новые сервисы
App\Services\Billing\PricingTierResolver — pure resolver:
final class PricingTierResolver
{
/**
* @param Collection<int, PricingTier> $tiers активные ступени, отсортированные по tier_no
* @return PricingTier ступень, в которую попадает N-й лид (1-based)
*/
public function resolveForCount(Collection $tiers, int $deliveredInMonth): PricingTier;
}
Логика: tier 1 покрывает 1..leads_in_tier; tier 2 — следующие leads_in_tier; ... tier 7 с leads_in_tier=NULL ловит всё свыше. Текущая активная сетка = MAX(effective_from) WHERE effective_from <= CURRENT_DATE AND is_active=true через PricingTierRepository::activeAt(Carbon $date).
App\Services\Billing\LedgerService — командный сервис, инжектится в job:
final class LedgerService
{
public function __construct(
private PricingTierResolver $resolver,
private PricingTierRepository $tiers,
) {}
/**
* Выполняется ВНУТРИ открытой DB-транзакции под lockForUpdate(Tenant).
* Возвращает ChargeResult с source ('prepaid'|'rub'); throws InsufficientBalanceException.
*/
public function chargeForDelivery(Tenant $lockedTenant, Deal $deal): ChargeResult;
}
App\Exceptions\Billing\InsufficientBalanceException — несёт priceKopecks, balanceRub, balanceLeads для логирования.
3.2. Точка интеграции — diff в createDealCopyForProject
Замена строк RouteSupplierLeadJob.php:265-279:
Было:
$tenant->decrement('balance_leads');
$tenant->refresh();
$project->increment('delivered_today');
$project->increment('delivered_in_month');
BalanceTransaction::create([
'tenant_id' => $tenant->id,
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
'amount_leads' => -1,
'balance_leads_after' => (int) $tenant->balance_leads,
'related_type' => Deal::class,
'related_id' => $deal->id,
'created_at' => now(),
]);
Станет:
try {
$chargeResult = $ledger->chargeForDelivery($tenant, $deal);
} catch (InsufficientBalanceException $e) {
Log::warning('billing.insufficient_balance.deal_rolled_back', [
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'supplier_lead_id' => $lead->id,
'price_kopecks' => $e->priceKopecks,
'balance_rub' => $e->balanceRub,
'balance_leads' => $e->balanceLeads,
]);
throw $e; // вылетает из DB::transaction → rollback Deal/Tenant lock/ActivityLog
}
$project->increment('delivered_today');
$project->increment('delivered_in_month');
3.3. Поток внутри LedgerService::chargeForDelivery
1. Считать активные tiers через PricingTierRepository::activeAt(today).
2. Resolve currentTier = PricingTierResolver::resolveForCount(tiers,
$lockedTenant->delivered_in_month + 1).
3. priceKopecks = currentTier->price_per_lead_kopecks.
4. Decide chargeSource (все денежные сравнения через bcmath — НЕ через PHP float):
- if $lockedTenant->balance_leads >= 1: source='prepaid'
- else if bccomp(bcmul((string) $lockedTenant->balance_rub, '100', 0),
(string) $priceKopecks, 0) >= 0: source='rub'
- else: throw InsufficientBalanceException(...)
5. Apply:
if source='prepaid':
- $lockedTenant->decrement('balance_leads', 1)
- $lockedTenant->increment('delivered_in_month', 1)
- $lockedTenant->refresh()
- INSERT lead_charges (charge_source='prepaid', tier_no=current,
price_per_lead_kopecks=0, charged_at=now)
- INSERT balance_transactions (type='lead_charge', amount_leads=-1,
balance_leads_after, related=Deal)
if source='rub':
- amount_rub = bcdiv($priceKopecks, '100', 2) (string-math, не float)
- $lockedTenant->decrement('balance_rub', $amount_rub)
- $lockedTenant->increment('delivered_in_month', 1)
- $lockedTenant->refresh()
- INSERT lead_charges (charge_source='rub', tier_no=current,
price_per_lead_kopecks=$priceKopecks, charged_at=now)
- INSERT balance_transactions (type='lead_charge', amount_rub=-$amount_rub,
balance_rub_after, related=Deal)
6. INSERT supplier_lead_costs (deal_id, received_at, supplier_id, cost_rub)
— закрывает gap (Plan 2/3 не писали в sharing-flow). supplier_id резолвится через
$lead->supplierProject->supplier_id (FK supplier_projects → suppliers); если null
(stub-проект без resolved supplier) — fallback в suppliers WHERE code = strtolower(platform).
cost_rub = $supplier->cost_rub (snapshot на момент INSERT'а).
7. return new ChargeResult(source, currentTier, priceKopecks)
Атомарность: все INSERT'ы — внутри одного DB::transaction родительского closure. Композитный DEFERRABLE FK lead_charges → deals(id, received_at) проверяется на commit.
Денежная арифметика: price_per_lead_kopecks (INTEGER) → balance_rub (DECIMAL 12,2) через bcdiv($priceKopecks, '100', 2), не PHP float. Tenant::decrement('balance_rub', $amount) использует SQL UPDATE (Eloquent), PG корректно держит в DECIMAL.
3.4. Concurrency и порядок locks
Текущий код держит lock'и в порядке (после Plan 2.5 fix #2):
Tenant::lockForUpdate(RouteSupplierLeadJob.php:188-191)Project::lockForUpdate(RouteSupplierLeadJob.php:199-202)
Plan 4 не меняет порядок. LedgerService::chargeForDelivery принимает уже locked $tenant — не делает повторный SELECT.
4. Monthly reset + balance-insufficient → auto-pause
4.1. ResetMonthlyCountersCommand (cron 1-го числа в 00:00 МСК)
Расширенный аналог ResetDeliveredTodayCommand:
namespace App\Console\Commands;
final class ResetMonthlyCountersCommand extends Command
{
protected $signature = 'projects:reset-monthly';
protected $description = 'Сброс tenants.delivered_in_month + projects.delivered_in_month = 0 (1-го числа в 00:00 МСК)';
public function handle(): int
{
DB::connection('pgsql_supplier')->transaction(function () {
$tenants = DB::connection('pgsql_supplier')
->update('UPDATE tenants SET delivered_in_month = 0 WHERE delivered_in_month <> 0');
$projects = DB::connection('pgsql_supplier')
->update('UPDATE projects SET delivered_in_month = 0 WHERE delivered_in_month <> 0');
$this->info("Monthly reset: {$tenants} tenants, {$projects} projects.");
});
return self::SUCCESS;
}
}
Schedule entry в routes/console.php — после reset-delivered-today:
Schedule::command('projects:reset-monthly')
->monthlyOn(1, '00:00')
->timezone('Europe/Moscow');
Идемпотентность: WHERE delivered_in_month <> 0 → повторный запуск даёт 0 affected.
4.2. Auto-pause при insufficient balance
InsufficientBalanceException ловится в createDealCopyForProject после rollback'а транзакции:
try {
return DB::transaction(function () use (...) { /* существующий код + LedgerService */ });
} catch (InsufficientBalanceException $e) {
$this->handleInsufficientBalance($lead, $project, $e);
return false;
}
private function handleInsufficientBalance(
SupplierLead $lead,
Project $project,
InsufficientBalanceException $e,
): void {
// UPDATE через pgsql_supplier (BYPASSRLS-роль crm_supplier_worker) — паттерн
// ResetDeliveredTodayCommand. Не используем SET LOCAL app.current_tenant_id,
// т.к. queue worker может работать под non-tenant context'ом.
DB::connection('pgsql_supplier')
->update('UPDATE projects SET is_active = false WHERE id = ?', [$project->id]);
$cacheKey = "billing:zero_balance_alert:{$project->tenant_id}";
if (Cache::add($cacheKey, true, now()->addHour())) {
app(NotificationService::class)->notifyZeroBalance($project->tenant, $project);
}
Log::warning('billing.project_paused_insufficient_balance', [
'tenant_id' => $project->tenant_id,
'project_id' => $project->id,
'supplier_lead_id' => $lead->id,
'price_kopecks' => $e->priceKopecks,
'balance_rub' => $e->balanceRub,
'balance_leads' => $e->balanceLeads,
]);
}
4.3. Семантика паузы
- Что пауза делает:
projects.is_active = false.LeadRouter::matchEligibleProjectsуже фильтруетWHERE is_active = true(Plan 1/2 паттерн — подтверждается тестом). - Что НЕ делает: не отменяет связь с supplier_projects, не сбрасывает счётчики, не трогает
SyncSupplierProjectsJob(но союз active-tenant'ов пересчитается на след. SyncJob run). - Re-activation: через UI пополнения баланса (Plan 4.5+) или manual toggle. Auto-resume — out of Plan 4.
4.4. Notification — notifyZeroBalance
NotificationService::notifyZeroBalance(Tenant $tenant, Project $project). Матрица notification_preferences (schema.sql:716) zero_balance дефолтно через email. Email через Unisender Go.
Mailable App\Mail\ZeroBalancePausedMail + blade resources/views/emails/zero_balance_paused.blade.php:
- Subject: «Проект "{name}" приостановлен — недостаточно средств»
- Body (русский): имя проекта, текущий баланс (rub и leads), требуемая ступень + цена, ссылка
/billing/topup.
4.5. Rate-limit алертов 1/час/tenant
Через Cache::add($key, true, now()->addHour()) — atomic Redis SETNX. Открытый вопрос #2: возможна корректировка.
5. CSV reconcile архитектура
5.1. Расширение SupplierPortalClient
Один новый публичный метод в SupplierPortalClient.php:65:
public function downloadLeadsCsv(CarbonInterface $from, CarbonInterface $to): string
{
$response = $this->request('GET', '/admin/report/index', [
'type' => 49,
'from' => $from->format('Y-m-d H:i:s'),
'to' => $to->format('Y-m-d H:i:s'),
]);
return $response->body();
}
request() (line 70) уже поддерживает GET + query-string + 401 retry через RefreshSupplierSessionJob + 5xx → SupplierTransientException + 4xx → SupplierClientException.
5.2. SupplierCsvParser
Pure парсер, streaming через generator:
namespace App\Services\Supplier;
final class SupplierCsvParser
{
/**
* Ожидаемые столбцы (placeholder — открытый вопрос #4):
* vid;project;tag;phone;phones;time
*
* @return iterable<int, array{vid: string, project: string, phone: string, time: int}>
*/
public function parse(string $rawCsv): iterable;
}
5.3. CsvReconcileJob
namespace App\Jobs\Supplier;
final class CsvReconcileJob implements ShouldQueue
{
public int $tries = 1;
public int $timeout = 300;
public function handle(
SupplierPortalClient $portal,
SupplierCsvParser $parser,
Mailer $mailer,
): void;
}
Алгоритм:
1. Cache::lock('supplier:csv_reconcile', 600) — защита от overlap'а.
2. INSERT supplier_csv_reconcile_log (status='running', started_at=now,
window_start=now-25h, window_end=now).
3. csv = $portal->downloadLeadsCsv(window_start, window_end).
4. rows = $parser->parse(csv) → собрать ['vid' => row, ...].
5. existing = SELECT vid FROM supplier_leads WHERE received_at BETWEEN window_start AND window_end
(через pgsql_supplier BYPASSRLS).
6. missing = array_diff_key(csvRows, existing).
7. drift_ratio = count(missing) / max(count(rows), 1).
8. Для каждой missing row:
- INSERT supplier_leads (vid, raw_payload=json_encode(row), received_at=row.time,
recovered_from_csv_at=now, supplier_project_id=null).
- dispatch(new RouteSupplierLeadJob($newLead->id)).
- recovered_count++.
9. UPDATE supplier_csv_reconcile_log:
total_csv_rows, matched_count, recovered_count, drift_ratio, finished_at, status.
10. if drift_ratio > 0.05:
- status='drift_alert', $mailer->send(CsvDriftAlertMail), alert_email_sent_at=now.
11. На исключение: status='failed', error_message, throw.
Окно — 25h (запас 1h над hourly cron'ом). Дубли через supplier_leads.vid UNIQUE — INSERT упадёт unique_violation, ловим, считаем как matched.
5.4. Schedule entry
После Plan 3 entries в routes/console.php:
Schedule::job(new CsvReconcileJob)->hourly();
Без withoutOverlapping() (нет cache_locks). Защита от overlap — внутренний Cache::lock (шаг 1).
5.5. Recovery flow
RouteSupplierLeadJob уже идемпотентен (Plan 2.5 fix #3 processed_at guard). recovered_from_csv_at маркер нужен для:
- Отчётности «webhook vs CSV» (spec §5.2).
- ActivityLog tag:
'source' => $lead->recovered_from_csv_at !== null ? 'supplier_csv_recovery' : 'supplier_webhook'.
5.6. CsvDriftAlertMail
Mailable + resources/views/emails/csv_drift_alert.blade.php:
- Subject: «Лидерра ↔ Поставщик: расхождение CSV > 5% за {window}»
- Body (русский): drift %, total_csv_rows, missing_count, recovered_count, window, ссылка на
supplier_csv_reconcile_log.id. - Адресат:
config('services.supplier.alert_email')— не верифицировал существование ключа в config из Plan 3; при impl грепнем и при необходимости добавим.
6. Admin UI экраны
6.1. /admin/pricing-tiers (SaaS-admin)
Frontend: resources/js/views/admin/AdminPricingTiersView.vue. Маршрут в resources/js/router/index.ts:
{ path: '/admin/pricing-tiers',
name: 'admin-pricing-tiers',
component: () => import('@/views/admin/AdminPricingTiersView.vue'),
meta: { layout: 'app', requiresAdmin: true } },
UI: Vue 3 + Vuetify 3, Forest-палитра, Inter + JetBrains Mono для цифр (font-feature-settings:'tnum').
<v-card>«Текущая активная сетка (с {effective_from})»:<v-data-table>7 строк (tier_no, leads_in_tier —NULL → «всё свыше», price отображается через computed «{руб},{коп}»).<v-card>«Запланированные изменения»: будущие pricing_tiers (effective_from > today) + «отменить запланированное».<v-btn>«Редактировать сетку» →<v-dialog>с 7-строчным редактором:<v-text-field type="number">дляleads_in_tier(последний disabled = «NULL»),<v-text-field>дляprice_rubс 2 десятичными.effective_from: read-only, auto = «1-е след. месяца».- POST
/api/admin/pricing-tiers(создаёт 7 новых строк с одинаковымeffective_from).
Backend: App\Http\Controllers\Api\AdminPricingTiersController:
GET /api/admin/pricing-tiers— active + scheduled.POST /api/admin/pricing-tiers— 7 новых rows сeffective_from = DATE_TRUNC('month', NOW() + INTERVAL '1 month'). Validation: ровно 7 tiers,tier_no1..7 unique,leads_in_tier > 0для 1..6,leads_in_tier IS NULLдля 7,price_per_lead_kopecks >= 0. Single transaction.DELETE /api/admin/pricing-tiers/scheduled/{effective_from}— отмена будущей сетки.
Маршрут в routes/web.php:99:
Route::prefix('/api/admin/pricing-tiers')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index');
Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store');
Route::delete('/scheduled/{effective_from}',
'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled')
->where('effective_from', '\d{4}-\d{2}-\d{2}');
});
Без auth middleware (паритет с другими /api/admin/*). Audit trail — saas_admin_audit_log.
6.2. /admin/supplier-prices (SaaS-admin)
Frontend: resources/js/views/admin/AdminSupplierPricesView.vue + маршрут /admin/supplier-prices.
UI: <v-card> + <v-data-table> 3 строки (B1/B2/B3): code, name, cost_rub (editable inline <v-text-field type="number" step="0.01">), quality_score (editable inline), is_active toggle. Кнопка «Сохранить».
Backend: App\Http\Controllers\Api\AdminSuppliersController:
GET /api/admin/suppliers— список 3 строк.PATCH /api/admin/suppliers/{id}— обновлениеcost_rub/quality_score/is_active. Audit log.
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
->where('id', '[0-9]+');
6.3. Tenant-side: «Списания» tab в BillingView
Не отдельный route — новый tab в существующем BillingView. Tabs: «Баланс» + «Списания» (новый) + «Тариф» (read-only показ pricing_tiers — открытый вопрос #3: всё или только текущая ступень).
UI resources/js/views/billing/ChargesTab.vue:
- Фильтры:
<v-select>период (текущий месяц / прошлый / 90 дней / диапазон),<v-select>charge_source. <v-data-table>: charged_at, deal_id (clickable →/deals/{id}), tier_no, charge_source (chip), price_rub, balance_rub_after.- Footer: «Всего за период: {N} лидов, {sum} ₽» + кнопка «Скачать CSV».
- Pagination server-side.
Backend: App\Http\Controllers\Api\TenantChargesController под auth:sanctum + tenant:
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\TenantChargesController@index');
Route::post('/export', 'App\Http\Controllers\Api\TenantChargesController@export');
});
GET /api/billing/charges— paginated lead_charges WHERE tenant_id = current (RLS защищает черезSetTenantContext). JOINpricing_tiersдля leads_in_tier.POST /api/billing/charges/export— CSV через OpenSpout +StreamedResponse(паттерн из DealExport).
6.4. Nav-tree
В AppLayout.vue — добавить пункты под admin-секцию (если user.is_saas_admin): «Тарифы» (/admin/pricing-tiers), «Поставщики» (/admin/supplier-prices).
Не верифицировал различение saas-admin vs tenant-user в nav-tree (связано с Б-1 / SSO). Грепнем при impl.
6.5. Stories для Histoire
AdminPricingTiersView.story.vue— 4 variants: пустая сетка / активная / scheduled / dialog open.AdminSupplierPricesView.story.vue— 2 variants: default / editing row.billing/ChargesTab.story.vue— 3 variants: пустой / mixed prepaid+rub / только rub.
6.6. A11y и стиль
- Vuetify-компоненты покрывают keyboard nav + ARIA.
- Числа — JetBrains Mono с
font-feature-settings:'tnum'. - Pa11y проверка после impl — 0 violations.
7. Тестовая стратегия и Acceptance Criteria
7.1. Сводка тестов
| Категория | Слой | Кол-во |
|---|---|---|
Pure unit на PricingTierResolver |
tests/Unit/Billing | 7 |
Integration LedgerService::chargeForDelivery |
tests/Feature/Billing | 6 |
E2E RouteSupplierLeadJob с биллингом |
tests/Feature/Supplier | 4 |
ResetMonthlyCountersCommand |
tests/Feature/Console | 4 |
| Auto-pause flow | tests/Feature/Supplier | 5 |
SupplierCsvParser |
tests/Unit/Supplier | 5 |
SupplierPortalClient::downloadLeadsCsv |
tests/Unit/Supplier | 3 |
CsvReconcileJob integration |
tests/Feature/Supplier | 6 |
| E2E CSV happy-path mock-server | tests/Browser (Linux CI only) | 1 |
AdminPricingTiersController |
tests/Feature/Admin | 8 |
AdminSuppliersController |
tests/Feature/Admin | 4 |
TenantChargesController |
tests/Feature/Billing | 6 |
| Frontend Vitest на 3 Vue компонента | resources/js/.../spec.ts | 12 |
| Итого новых тестов Plan 4 | 71 |
После Plan 4 baseline: ориентировочно 688 Pest + 405 Vitest (фактическое число подтвердится при impl).
7.2. Pre-commit (lefthook) gates
Без изменений в lefthook.yml: Pint / Larastan / squawk / pgFormatter / cspell / gitleaks / markdownlint — встраивается в существующие jobs. Larastan baseline вероятно расширится на ~5-10 entries.
7.3. Acceptance criteria
| AC | Описание | Verification |
|---|---|---|
| AC-1 | Schema v8.18 → v8.19: +1 таблица, +3 колонки, +3 индекса, +2 CHECK. | migrate:fresh зелёный; db/CHANGELOG_schema.md. |
| AC-2 | RouteSupplierLeadJob пишет lead_charges + supplier_lead_costs в одной транзакции с Deal. |
4 E2E теста (см. §7.1). |
| AC-3 | Dual-balance: balance_leads-first, balance_rub-second; tier через delivered_in_month + 1; bcmath. |
6 LedgerService тестов (см. §7.1). |
| AC-4 | ResetMonthlyCountersCommand + monthlyOn(1, '00:00') + Europe/Moscow. |
4 Console-теста + schedule:list. |
| AC-5 | Insufficient balance → exception → rollback → is_active=false + email (1/час/tenant). |
5 auto-pause тестов. |
| AC-6 | CsvReconcileJob hourly, окно 25h, drift > 5% → email, лог в supplier_csv_reconcile_log. |
6 integration + 1 E2E. |
| AC-7 | Admin UI: 7-tier editor, 3-row supplier prices, ChargesTab + CSV export. | 18 backend + 12 frontend + Histoire +9 variants + Pa11y 0. |
| AC-8 | Retry-idempotency: повторный run job'а не пишет дубль lead_charges. |
1 retry-idempotency тест. |
| AC-9 | Атомарные коммиты: 1 commit = 1 logical change. | git log review. |
| AC-10 | 0 schema-orphan FK / 0 duplicate CREATE TABLE / RLS-метрики 39. | self-review per Pravila §4.6. |
7.4. Verification gate перед merge (CV-pattern)
composer pint— clean diff.composer stan— 0 errors above baseline.composer test— все Pest зелёные.npm run lint:vue— clean.npm run type-check— 0 errors.npm run test:vue— все Vitest зелёные.npm run storybuild — все variants собираются.php artisan migrate:fresh --database=liderra_testing+ RLS smoke + model smoke.npm run a11y— 0 violations.gitleaks detect --no-bannerfull history — 0 leaks.npm run links(lychee) — 0 broken.- code-reviewer subagent → no BLOCKER.
- Manual smoke tinker
RouteSupplierLeadJob. - Manual UI smoke: pricing-tiers editor.
7.5. Risks / mitigations
| Risk | Mitigation |
|---|---|
delivered_in_month race 2 concurrent deliveries |
lockForUpdate(Tenant) уже держится; tier-lookup внутри lock → safe. |
| PHP float drift при price/100 | bcdiv($kopecks, '100', 2) — string-math; decimal:2 cast. |
| CSV-схема расходится с реальной (Tasks 1-2 BLOCKED) | Schema parser placeholder; Http::fake тесты; impl Task 0 = manual curl после credentials. |
| Reset cron не запустился вовремя | Idempotent UPDATE; manual php artisan projects:reset-monthly. |
| Redis crash → Cache::add false → ноль алертов | Log::warning всегда; observability-alert out of Plan 4. |
Pricing-tier editor: tier 7 c leads_in_tier ≠ NULL |
Validation на бэке: ровно одна row с NULL = tier 7. |
charge_source='prepaid' + price ≠ 0 |
CHECK chk_lead_charges_prepaid_zero_price. |
7.6. Открытые вопросы
| # | Вопрос | Кто решает | Дефолт |
|---|---|---|---|
| 1 | Дефолтные tier-цены (500/450/400/350/300/270/250 руб) | Заказчик | placeholder, ждём согласования перед seed'ом |
| 2 | Email rate-limit 1/час/tenant — норма? | Заказчик | 1/час |
| 3 | Tenant видит ВСЕ ступени или только свою? | Заказчик | transparent (все) |
| 4 | CSV-схема (точные столбцы из /admin/report/index?type=49) |
Discovery после credentials | placeholder webhook-payload |
| 5 | CSV окно (25h) — норма? | Заказчик/опыт | 25h |
| 6 | Drift threshold 5% — норма? | Заказчик/опыт | 5% |
| 7 | Pricing-tier-change при повышении цены | Заказчик | единая логика с понижением (effective 1-е след. месяца) |
Эти 7 — попадают в Открытые_вопросы реестра как новые Биз-* после approval'а спецификации.
7.7. Декомпозиция на impl-задачи (preview)
Детальный impl-plan будет написан через skill superpowers:writing-plans после approval'а spec'а. Preview:
- Task 1: Schema delta v8.18 → v8.19.
- Task 2:
PricingTierResolver(pure unit, TDD). - Task 3:
LedgerService::chargeForDelivery(integration, TDD). - Task 4: Integration
LedgerServiceвRouteSupplierLeadJob. - Task 5:
ResetMonthlyCountersCommand+ Schedule entry. - Task 6: Auto-pause flow +
ZeroBalancePausedMail+ rate-limit. - Task 7:
SupplierPortalClient::downloadLeadsCsv+SupplierCsvParser. - Task 8:
CsvReconcileJob+supplier_csv_reconcile_log+CsvDriftAlertMail. - Task 9:
AdminPricingTiersControllerbackend + Vue view + Histoire. - Task 10:
AdminSuppliersControllerbackend + Vue view + Histoire. - Task 11:
TenantChargesController+ ChargesTab + CSV export + Histoire. - Task 12: Verification gate (full CV) + code-review subagent.
12 Tasks. TDD: red → green → refactor; атомарный коммит на Task.
8. Ограничения и неверифицированное
В соответствии с экономия-0% дисциплиной фиксирую явно, что не верифицировано на момент написания spec'а:
LeadRouter::matchEligibleProjectsдействительно фильтруетWHERE is_active = true— будет проверено грепом и feature-тестом при impl.config('services.supplier.alert_email')ключ уже существует из Plan 3 — будет проверено грепом при impl.AppLayout.vueразличает saas-admin vs tenant-user nav-tree — будет проверено грепом при impl.- Точное число Pest baseline после Plan 4 (688) — округлённое; финальное число — после запуска
composer test. - Vuetify
<v-data-table>cells применяютfont-feature-settings:'tnum'без кастомного slot'а — будет проверено визуально при impl. - OpenSpout
StreamedResponseпаттерн в проекте — упомянут в memoryfeedback_environment.md, но конкретный пример код в DealExport не перечитывал; найдём при impl.
9. Источник истины и cross-refs
- Spec Plan 4 (этот файл): docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md
- Parent spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5.2, §7
- Schema: db/schema.sql (v8.18; будет v8.19 после impl)
- CLAUDE.md §6 (текущая фаза) — будет обновлён после merge Plan 4
- Pravila §4.5 (3 варианта) — overridden brainstorming-skill согласно §11.1 + §12