Files
portal/docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md
T
Дмитрий 901cf98281 docs(specs): Plan 4 (Billing + CSV Reconcile + Admin) implementation design
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>
2026-05-11 07:56:22 +03:00

42 KiB
Raw Blame History

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

  1. Dual balance: при доставке лида сначала пытаемся списать 1 единицу balance_leads (prepaid pool); если 0 — списываем из balance_rub по текущей tier-цене.
  2. Tier-lookup per-tenant: ступень определяется накопительной суммой tenants.delivered_in_month за месяц (новая колонка — см. §2).
  3. Атомарность: charge + deal-INSERT + counter increment — одна транзакция. lead_charges пишется всегда при успешной доставке: price_per_lead_kopecks=0 для prepaid, фактическая tier-цена для рублёвого списания. charge_source ENUM('prepaid','rub') для прозрачности.
  4. Pause-on-insufficient: если оба источника пусты — лид НЕ доставляется этому проекту, статус Лидерра-проекта → is_active=false + email уведомление с rate-limit 1/час/tenant.
  5. 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.
  6. SupplierLeadCost для sharing-flow: при каждой созданной deal-копии пишем supplier_lead_costs(deal_id, supplier_id, cost_rub=suppliers.cost_rub) — закрывает существующий gap (Plan 2/3 не писали).
  7. CSV reconcile: hourly. Сравнение по supplier_leads.vid за окно 25h. Drift > 5% → email админу. Recovered лиды идут через тот же RouteSupplierLeadJob с recovered_from_csv_at маркером.
  8. 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

  1. Balance top-up UI для admin/tenant — Plan 4.5 или Plan 5.
  2. Auto-resume project при пополнении баланса — Plan 4.5+.
  3. Подписочный биллинг / payments / счета-фактуры — Plan 5+.
  4. Per-tenant pricing override — out of MVP (spec §1.2).
  5. Replay сделок при изменении tier'а задним числом — нет (effective_from + append-only lead_charges).
  6. Schedule::onOneServer() для новых cron'ов — требует cache_locks таблицу, gated на Б-1 / Managed PG в Yandex Cloud.
  7. SaaS-admin auth middleware — gated на Б-1 + SSO заказчика; на MVP все /api/admin/* без middleware (паритет с Plans 1/2/3).
  8. Discovery CSV-схемы через credentials поставщика (паритет с Plan 3 Tasks 1+2 BLOCKED).
  9. 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.sqlschema.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_settings row 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

  1. tenants.delivered_in_month — после строки 644 (desired_daily_numbers), в секции «Биллинг».
  2. lead_charges.charge_source + CHECK — внутри CREATE TABLE lead_charges после строки 1008.
  3. supplier_csv_reconcile_log — новая секция после supplier_sync_log.
  4. supplier_leads.recovered_from_csv_at — внутри CREATE TABLE supplier_leads (v8.18).
  5. 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):

  1. Tenant::lockForUpdate (RouteSupplierLeadJob.php:188-191)
  2. 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 маркер нужен для:

  1. Отчётности «webhook vs CSV» (spec §5.2).
  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_no 1..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). JOIN pricing_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)

  1. composer pint — clean diff.
  2. composer stan — 0 errors above baseline.
  3. composer test — все Pest зелёные.
  4. npm run lint:vue — clean.
  5. npm run type-check — 0 errors.
  6. npm run test:vue — все Vitest зелёные.
  7. npm run story build — все variants собираются.
  8. php artisan migrate:fresh --database=liderra_testing + RLS smoke + model smoke.
  9. npm run a11y — 0 violations.
  10. gitleaks detect --no-banner full history — 0 leaks.
  11. npm run links (lychee) — 0 broken.
  12. code-reviewer subagent → no BLOCKER.
  13. Manual smoke tinker RouteSupplierLeadJob.
  14. 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:

  1. Task 1: Schema delta v8.18 → v8.19.
  2. Task 2: PricingTierResolver (pure unit, TDD).
  3. Task 3: LedgerService::chargeForDelivery (integration, TDD).
  4. Task 4: Integration LedgerService в RouteSupplierLeadJob.
  5. Task 5: ResetMonthlyCountersCommand + Schedule entry.
  6. Task 6: Auto-pause flow + ZeroBalancePausedMail + rate-limit.
  7. Task 7: SupplierPortalClient::downloadLeadsCsv + SupplierCsvParser.
  8. Task 8: CsvReconcileJob + supplier_csv_reconcile_log + CsvDriftAlertMail.
  9. Task 9: AdminPricingTiersController backend + Vue view + Histoire.
  10. Task 10: AdminSuppliersController backend + Vue view + Histoire.
  11. Task 11: TenantChargesController + ChargesTab + CSV export + Histoire.
  12. 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 паттерн в проекте — упомянут в memory feedback_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