Files
portal/docs/superpowers/plans/2026-05-11-plan4-billing-csv-admin-plan.md
T
Дмитрий fded2ee392 chore(lychee): Plan 4 plan-file fix 4 broken paths
Plan-файл лежит в docs/superpowers/plans/, поэтому относительный путь
../../docs/Открытые_вопросы_v8_3.md резолвится в docs/docs/... (двойной docs).
Корректный путь — ../../Открытые_вопросы_v8_3.md (мы уже в docs/).

+ escape line 4536 (placeholder `(path)` в example-template) как code block,
чтобы lychee не трактовал как реальную ссылку.

CV gate Step 1: lychee 298/228 OK/0 Errors/70 Excluded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:59:35 +03:00

173 KiB
Raw Blame History

Plan 4 (Billing + CSV Reconcile + Admin) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Активировать ступенчатый биллинг клиентов Лидерры (pricing_tiers / lead_charges никем не читались/писались), закрыть TODO «Биллинг per Plan 4» в RouteSupplierLeadJob.php:48; реализовать резервный CSV-канал приёма лидов через /admin/report/index?type=49 поставщика; собрать Admin UI (2 SaaS-admin страницы + 1 tab в существующем BillingView).

Architecture: 12 Tasks в 3 фазах. Фаза I — Billing core (Tasks 14): schema delta v8.18→v8.19, PricingTierResolver (pure), LedgerService с dual-balance prepaid-first логикой, integration в RouteSupplierLeadJob. Фаза II — Operations (Tasks 58): monthly reset cron, auto-pause flow с email rate-limit, CSV reconcile через расширение SupplierPortalClient + новый CsvReconcileJob hourly. Фаза III — UI (Tasks 912): pricing-tiers editor, supplier-prices editor, tenant ChargesTab + CSV export, финальная verification.

Tech Stack: PHP 8.3, Laravel 13.7, Pest 4, PostgreSQL 16 (pgsql_supplier BYPASSRLS connection из Plan 3 7899071), Redis 7 (Memurai на dev), Vue 3 + Vuetify 3, Vitest 4, Histoire 1.0-beta, OpenSpout (CSV streaming), bcmath (денежные сравнения), Unisender Go (email алерты).

Parent spec: 2026-05-11-plan4-billing-csv-admin-design.md (commit 901cf98). Inherits from: Plan 1 Foundation 001d781 + Plan 2 Webhook+Routing d5aa972 + Plan 2.5 hotfix c1ae195+1ba1df8 + Plan 2.6 cleanup 7899071 + Plan 3 Supplier Sync 734b0ab.


Карта файлов

Файл Действие Назначение Task
db/schema.sql Modify +1 таблица supplier_csv_reconcile_log, +3 колонки, +3 индекса, +2 CHECK, bump v8.18→v8.19 1
db/CHANGELOG_schema.md Modify Запись v8.19 1
db/02_grants.sql Modify +GRANT для supplier_csv_reconcile_log 1
app/database/seeders/PricingTierSeeder.php Create 7 дефолтных ступеней (effective_from='1970-01-01') 1
app/database/seeders/DatabaseSeeder.php Modify Подключить PricingTierSeeder 1
app/app/Models/Tenant.php Modify +delivered_in_month в $fillable + casts 1
app/app/Models/LeadCharge.php Modify +charge_source в $fillable + cast 1
app/app/Models/SupplierLead.php Modify +recovered_from_csv_at в $fillable + cast 1
app/database/factories/LeadChargeFactory.php Modify +charge_source='rub' дефолт 1
app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php Create Тесты: 4 новых колонки/таблица существуют, CHECK работает, idempotent fresh 1
app/app/Services/Billing/PricingTierResolver.php Create Pure resolver: «в какую ступень попадает N-й лид» 2
app/tests/Unit/Billing/PricingTierResolverTest.php Create 7 unit-тестов на math distribution 2
app/app/Repositories/PricingTierRepository.php Create DB-обёртка для current() ступеней 2
app/tests/Feature/Billing/PricingTierRepositoryTest.php Create 4 integration-теста (active/scheduled/empty) 2
app/app/Exceptions/Billing/InsufficientBalanceException.php Create DTO-exception с priceKopecks/balanceRub/balanceLeads 3
app/app/Services/Billing/ChargeResult.php Create Read-only DTO (source, tier, priceKopecks) 3
app/app/Services/Billing/LedgerService.php Create chargeForDelivery: dual-balance flow + lead_charges/supplier_lead_costs/balance_transactions INSERT 3
app/tests/Feature/Billing/LedgerServiceTest.php Create 6 integration-тестов на charge-flow 3
app/app/Jobs/RouteSupplierLeadJob.php Modify Заменить строки 265-279 на LedgerService::chargeForDelivery + try/catch InsufficientBalance 4
app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php Create 4 E2E теста sharing-flow с биллингом 4
app/app/Console/Commands/ResetMonthlyCountersCommand.php Create projects:reset-monthly для tenants + projects 5
app/routes/console.php Modify +Schedule monthlyOn(1, '00:00') Europe/Moscow 5
app/tests/Feature/Console/ResetMonthlyCountersCommandTest.php Create 4 теста idempotency + schedule введён 5
app/app/Mail/ZeroBalancePausedMail.php Create Mailable для алерта о паузе проекта 6
app/resources/views/emails/zero_balance_paused.blade.php Create Blade-шаблон email на русском 6
app/app/Services/NotificationService.php Modify +notifyZeroBalance(Tenant, Project) метод 6
app/app/Jobs/RouteSupplierLeadJob.php Modify +handleInsufficientBalance private method 6
app/tests/Feature/Supplier/AutoPauseFlowTest.php Create 5 тестов: pause + email + rate-limit + isolation 6
app/app/Services/Supplier/SupplierCsvParser.php Create Streaming-generator CSV parser 7
app/tests/Unit/Supplier/SupplierCsvParserTest.php Create 5 unit-тестов (empty/1row/1000rows/malformed/BOM) 7
app/app/Services/Supplier/SupplierPortalClient.php Modify +downloadLeadsCsv метод 7
app/tests/Unit/Supplier/SupplierPortalClientCsvTest.php Create 3 теста через Http::fake 7
app/app/Jobs/Supplier/CsvReconcileJob.php Create Hourly CSV reconcile + drift alert 8
app/app/Mail/CsvDriftAlertMail.php Create Mailable для алерта drift > 5% 8
app/resources/views/emails/csv_drift_alert.blade.php Create Blade-шаблон email на русском 8
app/routes/console.php Modify +Schedule::job(new CsvReconcileJob)->hourly() 8
app/tests/Feature/Supplier/CsvReconcileJobTest.php Create 6 integration-тестов + drift threshold 8
app/app/Http/Controllers/Api/AdminPricingTiersController.php Create GET/POST/DELETE pricing-tiers CRUD 9
app/routes/web.php Modify +Route prefix /api/admin/pricing-tiers 9
app/tests/Feature/Admin/AdminPricingTiersControllerTest.php Create 8 тестов (index/store/validate/delete) 9
app/resources/js/views/admin/AdminPricingTiersView.vue Create Vue 3 страница: 7-tier editor 9
app/resources/js/views/admin/AdminPricingTiersView.story.vue Create 4 Histoire variants 9
app/resources/js/router/index.ts Modify +route /admin/pricing-tiers 9
app/tests/Vitest/views/admin/AdminPricingTiersView.spec.ts Create 5 Vitest-тестов 9
app/app/Http/Controllers/Api/AdminSuppliersController.php Create GET/PATCH suppliers (B1/B2/B3) 10
app/routes/web.php Modify +Route /api/admin/suppliers 10
app/tests/Feature/Admin/AdminSuppliersControllerTest.php Create 4 теста 10
app/resources/js/views/admin/AdminSupplierPricesView.vue Create Vue 3 страница: B1/B2/B3 editor 10
app/resources/js/views/admin/AdminSupplierPricesView.story.vue Create 2 Histoire variants 10
app/resources/js/router/index.ts Modify +route /admin/supplier-prices 10
app/tests/Vitest/views/admin/AdminSupplierPricesView.spec.ts Create 3 Vitest-теста 10
app/app/Http/Controllers/Api/TenantChargesController.php Create GET (paginated) + POST export CSV 11
app/routes/web.php Modify +Route /api/billing/charges 11
app/tests/Feature/Billing/TenantChargesControllerTest.php Create 6 тестов (RLS isolation + pagination + filter + export) 11
app/resources/js/views/billing/ChargesTab.vue Create Vue компонент для tab «Списания» 11
app/resources/js/views/billing/ChargesTab.story.vue Create 3 Histoire variants 11
app/resources/js/views/BillingView.vue Modify Добавить ChargesTab внутри <v-tabs> 11
app/tests/Vitest/views/billing/ChargesTab.spec.ts Create 4 Vitest-теста 11
All test runs / CV.114 verification Verify Финальная проверка перед FF-merge 12
CLAUDE.md Modify §0 schema → v8.19; §6 фаза → Plan 4 closure (через claude-md-management) 12
docs/Открытые_вопросы_v8_3.md Modify +7 новых Биз-* (см. §7.6 spec'а) 12

Task 1: Schema delta v8.18 → v8.19 + models/factory/seeder updates

Files:

  • Modify: db/schema.sql (4 правки: tenants column, lead_charges column+check, supplier_leads column, supplier_csv_reconcile_log table)

  • Modify: db/CHANGELOG_schema.md

  • Modify: db/02_grants.sql

  • Modify: app/app/Models/Tenant.php

  • Modify: app/app/Models/LeadCharge.php

  • Modify: app/app/Models/SupplierLead.php

  • Modify: app/database/factories/LeadChargeFactory.php

  • Create: app/database/seeders/PricingTierSeeder.php

  • Modify: app/database/seeders/DatabaseSeeder.php

  • Create: app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php

  • Step 1: Написать failing schema-delta test

<?php
// app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php

declare(strict_types=1);

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

uses(\Tests\TestCase::class);

it('tenants table has delivered_in_month column with CHECK >= 0', function () {
    expect(Schema::hasColumn('tenants', 'delivered_in_month'))->toBeTrue();

    DB::table('tenants')->where('id', '<', 0)->update(['delivered_in_month' => 5]); // no-op
    expect(fn () => DB::statement(
        "INSERT INTO tenants (subdomain, organization_name, contact_email, webhook_token, delivered_in_month) ".
        "VALUES ('t-neg-test', 'X', 'x@x', 'wtok-neg-test-99999999', -1)"
    ))->toThrow(\Illuminate\Database\QueryException::class);
});

it('lead_charges table has charge_source column with CHECK on prepaid=zero-price', function () {
    expect(Schema::hasColumn('lead_charges', 'charge_source'))->toBeTrue();

    // Дефолтное значение 'rub' — проверяем через DEFAULT-инсерт.
    $tenant = \App\Models\Tenant::factory()->create();
    $deal = \App\Models\Deal::factory()->create(['tenant_id' => $tenant->id]);

    // 'prepaid' + price > 0 → CHECK fails
    expect(fn () => DB::table('lead_charges')->insert([
        'tenant_id' => $tenant->id,
        'deal_id' => $deal->id,
        'deal_received_at' => $deal->received_at,
        'tier_no' => 1,
        'price_per_lead_kopecks' => 50000,
        'charge_source' => 'prepaid',
        'charged_at' => now(),
        'created_at' => now(),
    ]))->toThrow(\Illuminate\Database\QueryException::class);
});

it('supplier_leads table has recovered_from_csv_at column', function () {
    expect(Schema::hasColumn('supplier_leads', 'recovered_from_csv_at'))->toBeTrue();
});

it('supplier_csv_reconcile_log table exists with required columns and status CHECK', function () {
    expect(Schema::hasTable('supplier_csv_reconcile_log'))->toBeTrue();
    expect(Schema::hasColumns('supplier_csv_reconcile_log', [
        'id', 'started_at', 'finished_at', 'window_start', 'window_end',
        'total_csv_rows', 'matched_count', 'recovered_count', 'drift_ratio',
        'status', 'error_message', 'alert_email_sent_at', 'created_at',
    ]))->toBeTrue();

    // CHECK на status
    expect(fn () => DB::table('supplier_csv_reconcile_log')->insert([
        'started_at' => now(),
        'window_start' => now()->subDay(),
        'window_end' => now(),
        'status' => 'unknown_status',
    ]))->toThrow(\Illuminate\Database\QueryException::class);
});

it('migrate:fresh is idempotent — re-run produces same metrics', function () {
    $beforeTables = count(DB::select("SELECT tablename FROM pg_tables WHERE schemaname='public'"));

    \Illuminate\Support\Facades\Artisan::call('migrate:fresh', ['--database' => 'pgsql', '--force' => true]);

    $afterTables = count(DB::select("SELECT tablename FROM pg_tables WHERE schemaname='public'"));
    expect($afterTables)->toBe($beforeTables);
});
  • Step 2: Запустить тест — должен FAIL (supplier_csv_reconcile_log отсутствует, колонок нет)
cd app && ./vendor/bin/pest --filter=SchemaDeltaTest tests/Feature/Plan4/Schema/SchemaDeltaTest.php

Expected output: 4 FAIL ("table/column does not exist"), 1 может PASS (migrate:fresh idempotent).

  • Step 3: Применить schema-патчи

Открыть db/schema.sql и внести 4 правки:

3a. Bump version header (строка ~7):

- -- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 webhook+routing: supplier_leads + projects.delivered_today + 2 system_settings seed)
+ -- Базовая версия: v8.19 (2026-05-11 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)

3b. tenants.delivered_in_month — добавить после строки 644 (desired_daily_numbers):

    -- v8.19 (Plan 4): месячный счётчик доставленных лидов per-tenant.
    -- Сбрасывается ResetMonthlyCountersCommand 1-го числа в 00:00 МСК (Europe/Moscow).
    -- Используется PricingTierResolver на горячем пути RouteSupplierLeadJob
    -- для O(1) lookup'а текущей ступени тарифа.
    delivered_in_month  INT NOT NULL DEFAULT 0
                        CHECK (delivered_in_month >= 0),

3c. lead_charges.charge_source + CHECK — внутри CREATE TABLE lead_charges (~строка 1001), добавить после price_per_lead_kopecks (~строка 1007) и перед charged_at:

    charge_source            VARCHAR(8) NOT NULL DEFAULT 'rub'
        CHECK (charge_source IN ('prepaid','rub')),

И добавить дополнительный CHECK после CREATE TABLE block перед CREATE INDEX:

ALTER TABLE lead_charges
    ADD CONSTRAINT chk_lead_charges_prepaid_zero_price
    CHECK (charge_source = 'rub' OR price_per_lead_kopecks = 0);

3d. supplier_leads.recovered_from_csv_at — внутри CREATE TABLE supplier_leads (Plan 2, ~строка 1840+, нужно грепнуть точное место), добавить после processed_at:

grep -n "CREATE TABLE supplier_leads" db/schema.sql
grep -n "processed_at" db/schema.sql | head -5

Добавить колонку:

    -- v8.19 (Plan 4 CSV reconcile): NULL для лидов из webhook (основной канал).
    -- Заполняется CsvReconcileJob при восстановлении лида, пропущенного webhook'ом.
    recovered_from_csv_at  TIMESTAMPTZ,

И partial index после CREATE TABLE block:

CREATE INDEX supplier_leads_recovered_from_csv_partial
    ON supplier_leads(recovered_from_csv_at)
    WHERE recovered_from_csv_at IS NOT NULL;

3e. supplier_csv_reconcile_log — новая секция после блока CREATE TABLE supplier_sync_log (Plan 3, ~строка 1140; грепнуть точное место):

grep -n "CREATE TABLE supplier_sync_log\b" db/schema.sql

Добавить ПОСЛЕ закрывающей ); и его индексов:


-- -----------------------------------------------------------------------------
-- supplier_csv_reconcile_log — журнал hourly CSV reconciliation (v8.19, Plan 4)
-- -----------------------------------------------------------------------------
-- SaaS-level (не tenant-scoped), без RLS. Аналог supplier_sync_log.
-- CsvReconcileJob записывает 1 строку на hourly run: started_at, окно,
-- метрики, drift_ratio. drift > 5% → email алерт; alert_email_sent_at timestamp.
-- Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
-- -----------------------------------------------------------------------------
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');

-- GRANT-policy в db/02_grants.sql (для prod). Dev: postgres superuser.
  • Step 4: Обновить db/CHANGELOG_schema.md

Добавить запись наверх (после v8.18 entry):

## v8.19 (2026-05-11) — Plan 4 Billing + CSV Reconcile + Admin

**Изменения:**
- `tenants` + колонка `delivered_in_month INTEGER NOT NULL DEFAULT 0 CHECK >= 0` (per-tenant счётчик для tier-lookup).
- `lead_charges` + колонка `charge_source VARCHAR(8) DEFAULT 'rub' CHECK IN ('prepaid','rub')` + CHECK `chk_lead_charges_prepaid_zero_price` (prepaid → price=0).
- `supplier_leads` + колонка `recovered_from_csv_at TIMESTAMPTZ` + partial index.
- Новая таблица `supplier_csv_reconcile_log` (SaaS-level, без RLS) + 2 индекса.
- 0 RLS-политик изменено.

**Метрики:** 61 → 62 базовых таблиц / 114 → 117 индексов / 39 RLS-политик (без изменений).

**Spec:** [docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md](../docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md).
  • Step 5: Обновить db/02_grants.sql

Добавить новую секцию для supplier_csv_reconcile_log:

-- =============================================================================
-- v8.19 (Plan 4): supplier_csv_reconcile_log — SaaS-уровневый журнал CSV recon.
-- Используется CsvReconcileJob под crm_supplier_worker (BYPASSRLS).
-- =============================================================================

GRANT SELECT, INSERT, UPDATE ON TABLE supplier_csv_reconcile_log TO crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE supplier_csv_reconcile_log_id_seq TO crm_supplier_worker;
  • Step 6: Обновить Eloquent-модели

Открыть app/app/Models/Tenant.php, добавить в $fillable:

'delivered_in_month',

В casts() метод (или protected $casts массив) — добавить:

'delivered_in_month' => 'integer',

Открыть app/app/Models/LeadCharge.php, добавить в $fillable (после tier_no):

'charge_source',

В casts():

'charge_source' => 'string',

Открыть app/app/Models/SupplierLead.php, добавить в $fillable:

'recovered_from_csv_at',

В casts():

'recovered_from_csv_at' => 'datetime',
  • Step 7: Обновить LeadChargeFactory
// app/database/factories/LeadChargeFactory.php — заменить definition()
public function definition(): array
{
    return [
        'tenant_id' => Tenant::factory(),
        'deal_id' => fake()->numberBetween(1, 99999),
        'deal_received_at' => now(),
        'tier_no' => fake()->numberBetween(1, 7),
        'price_per_lead_kopecks' => fake()->numberBetween(2000, 6000),
        'charge_source' => 'rub',
        'charged_at' => now(),
        'created_at' => now(),
    ];
}

/**
 * State для prepaid-списания (price=0).
 */
public function prepaid(): self
{
    return $this->state(fn () => [
        'charge_source' => 'prepaid',
        'price_per_lead_kopecks' => 0,
    ]);
}
  • Step 8: Создать PricingTierSeeder
<?php
// app/database/seeders/PricingTierSeeder.php

declare(strict_types=1);

namespace Database\Seeders;

use App\Models\PricingTier;
use Illuminate\Database\Seeder;

class PricingTierSeeder extends Seeder
{
    /**
     * 7 ступеней дефолтного тарифа (Plan 4 spec §2.3 — placeholder, ожидает
     * подтверждения заказчика. Открытый вопрос #1).
     */
    public function run(): void
    {
        $tiers = [
            ['tier_no' => 1, 'leads_in_tier' => 100,  'price_per_lead_kopecks' => 50000],
            ['tier_no' => 2, 'leads_in_tier' => 200,  'price_per_lead_kopecks' => 45000],
            ['tier_no' => 3, 'leads_in_tier' => 400,  'price_per_lead_kopecks' => 40000],
            ['tier_no' => 4, 'leads_in_tier' => 800,  'price_per_lead_kopecks' => 35000],
            ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_per_lead_kopecks' => 30000],
            ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_per_lead_kopecks' => 27000],
            ['tier_no' => 7, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 25000],
        ];

        foreach ($tiers as $tier) {
            PricingTier::updateOrCreate(
                ['tier_no' => $tier['tier_no'], 'effective_from' => '1970-01-01'],
                array_merge($tier, ['is_active' => true, 'effective_from' => '1970-01-01']),
            );
        }
    }
}
  • Step 9: Подключить seeder в DatabaseSeeder

Открыть app/database/seeders/DatabaseSeeder.php, в методе run() добавить:

$this->call(PricingTierSeeder::class);
  • Step 10: Прогнать migrate:fresh + seed на testing DB
cd app && DB_DATABASE=liderra_testing php artisan migrate:fresh --force --seed

Expected: 0 errors, «Database seeders ran successfully».

Verify pricing_tiers seed:

cd app && DB_DATABASE=liderra_testing php artisan tinker --execute="echo \App\Models\PricingTier::count();"

Expected output: 7.

  • Step 11: Прогнать failing schema-delta test — должен PASS
cd app && ./vendor/bin/pest --filter=SchemaDeltaTest tests/Feature/Plan4/Schema/SchemaDeltaTest.php

Expected: 5 PASS, 0 FAIL.

  • Step 12: Полный Pest прогон — убедиться, что добавление колонок ничего не сломало
cd app && ./vendor/bin/pest --parallel

Expected: все pre-existing тесты + 5 новых SchemaDeltaTest = PASS. Зафиксировать exact число в commit message.

  • Step 13: Запустить статанализ и pint
cd app && composer pint && composer stan

Expected: pint clean, stan 0 errors above baseline.

  • Step 14: Commit
git add db/schema.sql db/CHANGELOG_schema.md db/02_grants.sql \
        app/app/Models/Tenant.php app/app/Models/LeadCharge.php app/app/Models/SupplierLead.php \
        app/database/factories/LeadChargeFactory.php \
        app/database/seeders/PricingTierSeeder.php app/database/seeders/DatabaseSeeder.php \
        app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php
git commit -m "feat(db): Plan 4 Task 1 — schema delta v8.18 → v8.19 + models/seeder"

Task 2: PricingTierResolver (pure unit) + PricingTierRepository

Files:

  • Create: app/app/Services/Billing/PricingTierResolver.php

  • Create: app/tests/Unit/Billing/PricingTierResolverTest.php

  • Create: app/app/Repositories/PricingTierRepository.php

  • Create: app/tests/Feature/Billing/PricingTierRepositoryTest.php

  • Step 1: Написать failing unit-test для PricingTierResolver

<?php
// app/tests/Unit/Billing/PricingTierResolverTest.php

declare(strict_types=1);

use App\Models\PricingTier;
use App\Services\Billing\PricingTierResolver;
use Illuminate\Database\Eloquent\Collection;

uses(\Tests\TestCase::class);

beforeEach(function () {
    $this->resolver = new PricingTierResolver();

    // 7-tier сетка in-memory (без БД)
    $this->tiers = new Collection([
        new PricingTier(['tier_no' => 1, 'leads_in_tier' => 100,  'price_per_lead_kopecks' => 50000]),
        new PricingTier(['tier_no' => 2, 'leads_in_tier' => 200,  'price_per_lead_kopecks' => 45000]),
        new PricingTier(['tier_no' => 3, 'leads_in_tier' => 400,  'price_per_lead_kopecks' => 40000]),
        new PricingTier(['tier_no' => 4, 'leads_in_tier' => 800,  'price_per_lead_kopecks' => 35000]),
        new PricingTier(['tier_no' => 5, 'leads_in_tier' => 1500, 'price_per_lead_kopecks' => 30000]),
        new PricingTier(['tier_no' => 6, 'leads_in_tier' => 3000, 'price_per_lead_kopecks' => 27000]),
        new PricingTier(['tier_no' => 7, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 25000]),
    ]);
});

it('returns tier 1 for the 1st lead', function () {
    $tier = $this->resolver->resolveForCount($this->tiers, 1);
    expect($tier->tier_no)->toBe(1);
});

it('returns tier 1 at upper bound (100th lead)', function () {
    $tier = $this->resolver->resolveForCount($this->tiers, 100);
    expect($tier->tier_no)->toBe(1);
});

it('crosses to tier 2 at 101st lead', function () {
    $tier = $this->resolver->resolveForCount($this->tiers, 101);
    expect($tier->tier_no)->toBe(2);
});

it('returns tier 6 at cumulative sum of tiers 1-6 (5000th lead)', function () {
    // 100 + 200 + 400 + 800 + 1500 + 3000 = 6000; last in tier 6 = 6000th lead
    $tier = $this->resolver->resolveForCount($this->tiers, 6000);
    expect($tier->tier_no)->toBe(6);
});

it('returns tier 7 (unlimited) for 6001st lead', function () {
    $tier = $this->resolver->resolveForCount($this->tiers, 6001);
    expect($tier->tier_no)->toBe(7);
});

it('returns tier 7 for 1_000_000th lead (unlimited)', function () {
    $tier = $this->resolver->resolveForCount($this->tiers, 1_000_000);
    expect($tier->tier_no)->toBe(7);
});

it('throws RuntimeException on empty tiers collection', function () {
    expect(fn () => $this->resolver->resolveForCount(new Collection(), 1))
        ->toThrow(\RuntimeException::class, 'No active pricing tiers');
});
  • Step 2: Запустить test — FAIL (класс не существует)
cd app && ./vendor/bin/pest tests/Unit/Billing/PricingTierResolverTest.php

Expected: FAIL "Class PricingTierResolver does not exist".

  • Step 3: Реализовать PricingTierResolver
<?php
// app/app/Services/Billing/PricingTierResolver.php

declare(strict_types=1);

namespace App\Services\Billing;

use App\Models\PricingTier;
use Illuminate\Database\Eloquent\Collection;
use RuntimeException;

/**
 * Pure resolver: «в какую ступень pricing_tiers попадает N-й лид» (1-based).
 *
 * Логика: tier 1 покрывает 1..leads_in_tier_1; tier 2 — следующие leads_in_tier_2;
 * tier 7 с leads_in_tier=NULL ловит всё свыше суммарного объёма tiers 1-6.
 *
 * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3.1
 */
final class PricingTierResolver
{
    /**
     * @param  Collection<int, PricingTier>  $tiers  активные ступени (не обязательно отсортированы)
     * @param  int  $leadOrdinal  номер лида в текущем месяце (1-based)
     */
    public function resolveForCount(Collection $tiers, int $leadOrdinal): PricingTier
    {
        if ($tiers->isEmpty()) {
            throw new RuntimeException('No active pricing tiers — cannot resolve');
        }

        /** @var Collection<int, PricingTier> $sorted */
        $sorted = $tiers->sortBy('tier_no')->values();

        $cumulative = 0;
        foreach ($sorted as $tier) {
            // tier 7 (или любой с leads_in_tier=NULL) — «всё свыше»
            if ($tier->leads_in_tier === null) {
                return $tier;
            }

            $cumulative += (int) $tier->leads_in_tier;
            if ($leadOrdinal <= $cumulative) {
                return $tier;
            }
        }

        // Если ни одна ступень не покрыла (leadOrdinal > сумма всех leads_in_tier
        // И ни у одной не было NULL) — возвращаем последнюю как fallback.
        return $sorted->last();
    }
}
  • Step 4: Запустить test — PASS
cd app && ./vendor/bin/pest tests/Unit/Billing/PricingTierResolverTest.php

Expected: 7 PASS.

  • Step 5: Написать failing test для PricingTierRepository
<?php
// app/tests/Feature/Billing/PricingTierRepositoryTest.php

declare(strict_types=1);

use App\Models\PricingTier;
use App\Repositories\PricingTierRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;

uses(\Tests\TestCase::class, RefreshDatabase::class);

beforeEach(function () {
    $this->repo = new PricingTierRepository();
});

it('returns active tiers ordered by tier_no', function () {
    PricingTier::factory()->create(['tier_no' => 2, 'effective_from' => '2024-01-01', 'is_active' => true]);
    PricingTier::factory()->create(['tier_no' => 1, 'effective_from' => '2024-01-01', 'is_active' => true]);

    $tiers = $this->repo->activeAt(Carbon::parse('2024-06-01'));

    expect($tiers->pluck('tier_no')->all())->toBe([1, 2]);
});

it('returns max effective_from <= today (newer overrides older)', function () {
    PricingTier::factory()->create(['tier_no' => 1, 'effective_from' => '2024-01-01', 'price_per_lead_kopecks' => 50000]);
    PricingTier::factory()->create(['tier_no' => 1, 'effective_from' => '2024-06-01', 'price_per_lead_kopecks' => 30000]);

    $tiers = $this->repo->activeAt(Carbon::parse('2024-07-01'));

    expect($tiers->first()->price_per_lead_kopecks)->toBe(30000);
});

it('ignores future effective_from', function () {
    PricingTier::factory()->create(['tier_no' => 1, 'effective_from' => '2024-01-01', 'price_per_lead_kopecks' => 50000]);
    PricingTier::factory()->create(['tier_no' => 1, 'effective_from' => '2099-01-01', 'price_per_lead_kopecks' => 99999]);

    $tiers = $this->repo->activeAt(Carbon::parse('2024-06-01'));

    expect($tiers->first()->price_per_lead_kopecks)->toBe(50000);
});

it('returns empty collection when no active tiers exist', function () {
    $tiers = $this->repo->activeAt(Carbon::parse('2024-06-01'));

    expect($tiers)->toBeEmpty();
});
  • Step 6: Запустить — FAIL
cd app && ./vendor/bin/pest tests/Feature/Billing/PricingTierRepositoryTest.php

Expected: FAIL "Class PricingTierRepository does not exist".

  • Step 7: Реализовать PricingTierRepository
<?php
// app/app/Repositories/PricingTierRepository.php

declare(strict_types=1);

namespace App\Repositories;

use App\Models\PricingTier;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Collection;

/**
 * DB-обёртка для текущей активной сетки pricing_tiers.
 *
 * Логика: для данной даты `$at` возвращаются все ступени с
 * MAX(effective_from) WHERE effective_from <= $at AND is_active=true,
 * сгруппированные по tier_no.
 *
 * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3.1
 */
final class PricingTierRepository
{
    /**
     * @return Collection<int, PricingTier>
     */
    public function activeAt(CarbonInterface $at): Collection
    {
        // Для каждого tier_no берём строку с MAX(effective_from) <= $at
        // (учёт сценария «новая сетка перекрывает старую»).
        return PricingTier::query()
            ->where('is_active', true)
            ->where('effective_from', '<=', $at->toDateString())
            ->orderBy('tier_no')
            ->orderBy('effective_from', 'desc')
            ->get()
            ->groupBy('tier_no')
            ->map(fn (Collection $group) => $group->first())
            ->values()
            ->sortBy('tier_no')
            ->values();
    }
}
  • Step 8: Запустить test — PASS
cd app && ./vendor/bin/pest tests/Feature/Billing/PricingTierRepositoryTest.php

Expected: 4 PASS.

  • Step 9: Pint + Larastan + полный Pest
cd app && composer pint && composer stan && ./vendor/bin/pest --parallel

Expected: pint clean, stan 0 above baseline, все тесты PASS.

  • Step 10: Commit
git add app/app/Services/Billing/PricingTierResolver.php \
        app/app/Repositories/PricingTierRepository.php \
        app/tests/Unit/Billing/PricingTierResolverTest.php \
        app/tests/Feature/Billing/PricingTierRepositoryTest.php
git commit -m "feat(billing): Plan 4 Task 2 — PricingTierResolver + Repository (pure resolver + DB-обёртка)"

Task 3: LedgerService::chargeForDelivery + ChargeResult + InsufficientBalanceException

Files:

  • Create: app/app/Exceptions/Billing/InsufficientBalanceException.php

  • Create: app/app/Services/Billing/ChargeResult.php

  • Create: app/app/Services/Billing/LedgerService.php

  • Create: app/tests/Feature/Billing/LedgerServiceTest.php

  • Step 1: Создать InsufficientBalanceException

<?php
// app/app/Exceptions/Billing/InsufficientBalanceException.php

declare(strict_types=1);

namespace App\Exceptions\Billing;

use RuntimeException;

/**
 * Выбрасывается LedgerService::chargeForDelivery, когда tenant не имеет
 * ни prepaid-лидов (balance_leads >= 1), ни рублей под текущую tier-цену
 * (balance_rub * 100 >= priceKopecks).
 *
 * Ловится в RouteSupplierLeadJob::createDealCopyForProject — инициирует
 * auto-pause flow (см. spec §4.2).
 */
final class InsufficientBalanceException extends RuntimeException
{
    public function __construct(
        public readonly int $priceKopecks,
        public readonly string $balanceRub,    // строка для bcmath compatibility
        public readonly int $balanceLeads,
        ?\Throwable $previous = null,
    ) {
        parent::__construct(
            sprintf(
                'Insufficient balance: price_kopecks=%d, balance_rub=%s, balance_leads=%d',
                $priceKopecks, $balanceRub, $balanceLeads,
            ),
            previous: $previous,
        );
    }
}
  • Step 2: Создать ChargeResult DTO
<?php
// app/app/Services/Billing/ChargeResult.php

declare(strict_types=1);

namespace App\Services\Billing;

use App\Models\PricingTier;

/**
 * Read-only DTO с результатом charge'а: source (prepaid/rub), снимок ступени, цена в копейках.
 */
final readonly class ChargeResult
{
    public function __construct(
        public string $source,           // 'prepaid' | 'rub'
        public PricingTier $tier,
        public int $priceKopecks,
    ) {}
}
  • Step 3: Написать failing test для LedgerService
<?php
// app/tests/Feature/Billing/LedgerServiceTest.php

declare(strict_types=1);

use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\PricingTier;
use App\Models\Supplier;
use App\Models\SupplierProject;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;

uses(\Tests\TestCase::class, RefreshDatabase::class);

beforeEach(function () {
    // Seed 7 ступеней
    \Database\Seeders\PricingTierSeeder::class;
    (new \Database\Seeders\PricingTierSeeder())->run();

    $this->ledger = app(LedgerService::class);
});

function makeTenantWith(int $balanceLeads, string $balanceRub, int $deliveredInMonth = 0): Tenant
{
    return Tenant::factory()->create([
        'balance_leads' => $balanceLeads,
        'balance_rub' => $balanceRub,
        'delivered_in_month' => $deliveredInMonth,
    ]);
}

function makeDealForTenant(Tenant $tenant): Deal
{
    return Deal::factory()->create([
        'tenant_id' => $tenant->id,
        'received_at' => now(),
    ]);
}

it('charges prepaid when balance_leads >= 1 (price snapshot = 0, tier_no snapshot from delivered_in_month + 1)', function () {
    $tenant = makeTenantWith(balanceLeads: 5, balanceRub: '0.00', deliveredInMonth: 0);
    $deal = makeDealForTenant($tenant);

    $result = DB::transaction(function () use ($tenant, $deal) {
        DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
        $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail();
        return $this->ledger->chargeForDelivery($locked, $deal);
    });

    expect($result->source)->toBe('prepaid');
    expect($result->priceKopecks)->toBe(0);
    expect($result->tier->tier_no)->toBe(1);  // 0+1 = 1st lead → tier 1

    $tenant->refresh();
    expect((int) $tenant->balance_leads)->toBe(4);
    expect((string) $tenant->balance_rub)->toBe('0.00');
    expect($tenant->delivered_in_month)->toBe(1);

    $charge = LeadCharge::first();
    expect($charge->charge_source)->toBe('prepaid');
    expect($charge->price_per_lead_kopecks)->toBe(0);
    expect($charge->tier_no)->toBe(1);
});

it('charges rub when balance_leads = 0 and balance_rub >= price', function () {
    $tenant = makeTenantWith(balanceLeads: 0, balanceRub: '1000.00', deliveredInMonth: 0);
    $deal = makeDealForTenant($tenant);

    $result = DB::transaction(function () use ($tenant, $deal) {
        DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
        $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail();
        return $this->ledger->chargeForDelivery($locked, $deal);
    });

    expect($result->source)->toBe('rub');
    expect($result->priceKopecks)->toBe(50000);  // tier 1 price

    $tenant->refresh();
    expect((string) $tenant->balance_rub)->toBe('500.00');  // 1000 - 500
    expect((int) $tenant->balance_leads)->toBe(0);
    expect($tenant->delivered_in_month)->toBe(1);

    $charge = LeadCharge::first();
    expect($charge->charge_source)->toBe('rub');
    expect($charge->price_per_lead_kopecks)->toBe(50000);
});

it('throws InsufficientBalanceException when both sources empty', function () {
    $tenant = makeTenantWith(balanceLeads: 0, balanceRub: '400.00', deliveredInMonth: 0);
    $deal = makeDealForTenant($tenant);

    expect(function () use ($tenant, $deal) {
        DB::transaction(function () use ($tenant, $deal) {
            DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
            $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail();
            $this->ledger->chargeForDelivery($locked, $deal);
        });
    })->toThrow(InsufficientBalanceException::class);

    expect(LeadCharge::count())->toBe(0);
    $tenant->refresh();
    expect((int) $tenant->balance_leads)->toBe(0);
    expect((string) $tenant->balance_rub)->toBe('400.00');
    expect($tenant->delivered_in_month)->toBe(0);
});

it('charges rub at exact balance == price boundary', function () {
    $tenant = makeTenantWith(balanceLeads: 0, balanceRub: '500.00', deliveredInMonth: 0);
    $deal = makeDealForTenant($tenant);

    $result = DB::transaction(function () use ($tenant, $deal) {
        DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
        $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail();
        return $this->ledger->chargeForDelivery($locked, $deal);
    });

    expect($result->source)->toBe('rub');

    $tenant->refresh();
    expect((string) $tenant->balance_rub)->toBe('0.00');
});

it('crosses tier boundary: delivered_in_month=99 → tier 1; delivered_in_month=100 → tier 2', function () {
    $tenantA = makeTenantWith(balanceLeads: 0, balanceRub: '10000.00', deliveredInMonth: 99);
    $tenantB = makeTenantWith(balanceLeads: 0, balanceRub: '10000.00', deliveredInMonth: 100);

    $dealA = makeDealForTenant($tenantA);
    $dealB = makeDealForTenant($tenantB);

    $resultA = DB::transaction(function () use ($tenantA, $dealA) {
        DB::statement("SET LOCAL app.current_tenant_id = '{$tenantA->id}'");
        $locked = Tenant::whereKey($tenantA->id)->lockForUpdate()->firstOrFail();
        return $this->ledger->chargeForDelivery($locked, $dealA);
    });
    $resultB = DB::transaction(function () use ($tenantB, $dealB) {
        DB::statement("SET LOCAL app.current_tenant_id = '{$tenantB->id}'");
        $locked = Tenant::whereKey($tenantB->id)->lockForUpdate()->firstOrFail();
        return $this->ledger->chargeForDelivery($locked, $dealB);
    });

    expect($resultA->tier->tier_no)->toBe(1);  // 99+1 = 100, всё ещё в tier 1
    expect($resultB->tier->tier_no)->toBe(2);  // 100+1 = 101, перешли в tier 2
});

it('writes supplier_lead_costs (gap-fix: Plan 2/3 не писали в sharing-flow)', function () {
    $tenant = makeTenantWith(balanceLeads: 5, balanceRub: '0.00');
    $supplier = Supplier::where('code', 'b1')->first();
    $supplierProject = SupplierProject::factory()->create(['platform' => 'B1', 'supplier_id' => $supplier->id]);
    $deal = Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()]);
    $lead = SupplierLead::factory()->create(['supplier_project_id' => $supplierProject->id]);

    // Деталь: chargeForDelivery должен резолвить supplier_id из $lead->supplierProject->supplier_id
    // через explicit binding в LedgerService (мы передадим $lead с deal вместе).
    // Этот тест проверяет supplier_lead_costs INSERT после charge'а.

    DB::transaction(function () use ($tenant, $deal, $lead) {
        DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
        $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail();
        $this->ledger->chargeForDelivery($locked, $deal, $lead);
    });

    $cost = DB::table('supplier_lead_costs')->where('deal_id', $deal->id)->first();
    expect($cost)->not->toBeNull();
    expect((int) $cost->supplier_id)->toBe($supplier->id);
    expect((string) $cost->cost_rub)->toBe($supplier->cost_rub);
});
  • Step 4: Запустить test — FAIL
cd app && ./vendor/bin/pest tests/Feature/Billing/LedgerServiceTest.php

Expected: 6 FAIL ("Class LedgerService does not exist" / DI resolve failure).

  • Step 5: Реализовать LedgerService
<?php
// app/app/Services/Billing/LedgerService.php

declare(strict_types=1);

namespace App\Services\Billing;

use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Supplier;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

/**
 * Командный сервис биллинга на горячем пути доставки лида.
 *
 * Контракт: вызывается ВНУТРИ открытой DB-транзакции под lockForUpdate(Tenant).
 * Применяет dual-balance flow:
 *   1. tier-lookup по tenants.delivered_in_month + 1
 *   2. prepaid: balance_leads--, lead_charges (price=0)
 *   3. rub: balance_rub -= price/100 (bcmath), lead_charges (price=tier)
 *   4. INSERT supplier_lead_costs (gap-fix sharing-flow)
 *   5. INSERT balance_transactions (universal ledger движения баланса)
 *
 * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3
 */
final class LedgerService
{
    public function __construct(
        private readonly PricingTierResolver $resolver,
        private readonly PricingTierRepository $tiers,
    ) {}

    public function chargeForDelivery(
        Tenant $lockedTenant,
        Deal $deal,
        ?SupplierLead $lead = null,
    ): ChargeResult {
        // 1. tier-resolution для (delivered_in_month + 1)-го лида
        $activeTiers = $this->tiers->activeAt(Carbon::now('Europe/Moscow'));
        $tier = $this->resolver->resolveForCount($activeTiers, ($lockedTenant->delivered_in_month ?? 0) + 1);
        $priceKopecks = (int) $tier->price_per_lead_kopecks;

        // 2. Decide chargeSource (bcmath — НЕ PHP float)
        $source = $this->decideSource($lockedTenant, $priceKopecks);

        // 3. Apply
        if ($source === 'prepaid') {
            $lockedTenant->decrement('balance_leads', 1);
        } else {
            $amountRub = bcdiv((string) $priceKopecks, '100', 2);
            $lockedTenant->decrement('balance_rub', $amountRub);
        }
        $lockedTenant->increment('delivered_in_month', 1);
        $lockedTenant->refresh();

        // 4. INSERT lead_charges (always)
        LeadCharge::create([
            'tenant_id' => $lockedTenant->id,
            'deal_id' => $deal->id,
            'deal_received_at' => $deal->received_at,
            'tier_no' => $tier->tier_no,
            'price_per_lead_kopecks' => $source === 'prepaid' ? 0 : $priceKopecks,
            'charge_source' => $source,
            'charged_at' => now(),
            'created_at' => now(),
        ]);

        // 5. INSERT balance_transactions (универсальный ledger)
        BalanceTransaction::create([
            'tenant_id' => $lockedTenant->id,
            'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
            'amount_leads' => $source === 'prepaid' ? -1 : 0,
            'amount_rub' => $source === 'rub' ? '-'.bcdiv((string) $priceKopecks, '100', 2) : '0.00',
            'balance_leads_after' => (int) $lockedTenant->balance_leads,
            'balance_rub_after' => (string) $lockedTenant->balance_rub,
            'related_type' => Deal::class,
            'related_id' => $deal->id,
            'created_at' => now(),
        ]);

        // 6. INSERT supplier_lead_costs (gap-fix Plan 2/3 sharing-flow)
        if ($lead !== null) {
            $supplierId = $this->resolveSupplierId($lead);
            if ($supplierId !== null) {
                /** @var Supplier $supplier */
                $supplier = Supplier::findOrFail($supplierId);
                DB::table('supplier_lead_costs')->insert([
                    'deal_id' => $deal->id,
                    'received_at' => $deal->received_at,
                    'supplier_id' => $supplierId,
                    'cost_rub' => $supplier->cost_rub,
                    'created_at' => now(),
                ]);
            }
        }

        return new ChargeResult($source, $tier, $source === 'prepaid' ? 0 : $priceKopecks);
    }

    private function decideSource(Tenant $tenant, int $priceKopecks): string
    {
        if ((int) $tenant->balance_leads >= 1) {
            return 'prepaid';
        }

        // bcmath: balance_rub (DECIMAL string) * 100 ≥ priceKopecks → можем списать rub
        $balanceKopecks = bcmul((string) $tenant->balance_rub, '100', 0);
        if (bccomp($balanceKopecks, (string) $priceKopecks, 0) >= 0) {
            return 'rub';
        }

        throw new InsufficientBalanceException(
            priceKopecks: $priceKopecks,
            balanceRub: (string) $tenant->balance_rub,
            balanceLeads: (int) $tenant->balance_leads,
        );
    }

    /**
     * supplier_id из $lead->supplier_project->supplier_id (FK); fallback —
     * по platform из raw_payload через suppliers.code.
     */
    private function resolveSupplierId(SupplierLead $lead): ?int
    {
        if ($lead->supplier_project_id !== null) {
            $sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first();
            if ($sp !== null && $sp->supplier_id !== null) {
                return (int) $sp->supplier_id;
            }
        }
        // Fallback: парсим platform из raw_payload['project'] (B1_xxx → 'b1')
        $project = (string) ($lead->raw_payload['project'] ?? '');
        if (preg_match('/^(B[123])_/', $project, $m) === 1) {
            $code = strtolower($m[1]);
            $supplier = Supplier::where('code', $code)->first();
            return $supplier?->id;
        }
        return null;
    }
}
  • Step 6: Запустить test — PASS
cd app && ./vendor/bin/pest tests/Feature/Billing/LedgerServiceTest.php

Expected: 6 PASS.

  • Step 7: Pint + Larastan + полный Pest
cd app && composer pint && composer stan && ./vendor/bin/pest --parallel

Expected: pint clean, stan 0 above baseline, все тесты PASS.

  • Step 8: Commit
git add app/app/Exceptions/Billing/ app/app/Services/Billing/ app/tests/Feature/Billing/LedgerServiceTest.php
git commit -m "feat(billing): Plan 4 Task 3 — LedgerService::chargeForDelivery (dual-balance + lead_charges/supplier_lead_costs INSERT)"

Task 4: Integration LedgerService в RouteSupplierLeadJob

Files:

  • Modify: app/app/Jobs/RouteSupplierLeadJob.php (заменить строки 265-279 + добавить try/catch для InsufficientBalance)

  • Create: app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php

  • Step 1: Написать failing E2E тест

<?php
// app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php

declare(strict_types=1);

use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\Supplier;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;

uses(\Tests\TestCase::class, RefreshDatabase::class);

beforeEach(function () {
    (new \Database\Seeders\PricingTierSeeder())->run();
});

function prepareSharingFlow(int $tenantsCount, array $balances): array
{
    /** @var array<int, Tenant> $tenants */
    $tenants = [];
    /** @var array<int, Project> $projects */
    $projects = [];

    $supplier = Supplier::where('code', 'b1')->first();
    $supplierProject = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'example.com',
        'supplier_id' => $supplier->id,
    ]);

    for ($i = 0; $i < $tenantsCount; $i++) {
        $tenant = Tenant::factory()->create($balances[$i]);
        $project = Project::factory()->create([
            'tenant_id' => $tenant->id,
            'signal_type' => 'site',
            'signal_identifier' => 'example.com',
            'supplier_b1_project_id' => $supplierProject->id,
            'is_active' => true,
            'daily_limit_target' => 10,
            'effective_daily_limit_today' => 10,
            'delivered_today' => 0,
            'delivery_days_mask' => 127,  // все дни
            'region_mask' => 255,
        ]);
        $tenants[] = $tenant;
        $projects[] = $project;
    }

    $lead = SupplierLead::factory()->create([
        'vid' => 'test-vid-'.uniqid(),
        'phone' => '79991234567',
        'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234567', 'time' => time()],
        'supplier_project_id' => $supplierProject->id,
        'received_at' => now(),
    ]);

    return ['tenants' => $tenants, 'projects' => $projects, 'lead' => $lead, 'supplier' => $supplier];
}

it('charges prepaid for tenant with balance_leads > 0', function () {
    $ctx = prepareSharingFlow(1, [['balance_leads' => 5, 'balance_rub' => '0.00', 'delivered_in_month' => 0]]);

    (new RouteSupplierLeadJob($ctx['lead']->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
    );

    $tenant = $ctx['tenants'][0]->fresh();
    expect((int) $tenant->balance_leads)->toBe(4);
    expect($tenant->delivered_in_month)->toBe(1);

    $charge = LeadCharge::first();
    expect($charge)->not->toBeNull();
    expect($charge->charge_source)->toBe('prepaid');
    expect($charge->price_per_lead_kopecks)->toBe(0);
});

it('charges rub for tenant with balance_leads=0 and balance_rub >= price', function () {
    $ctx = prepareSharingFlow(1, [['balance_leads' => 0, 'balance_rub' => '1000.00', 'delivered_in_month' => 0]]);

    (new RouteSupplierLeadJob($ctx['lead']->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
    );

    $tenant = $ctx['tenants'][0]->fresh();
    expect((string) $tenant->balance_rub)->toBe('500.00');  // 1000 - 500 (tier 1 = 50000 коп)
    expect($tenant->delivered_in_month)->toBe(1);

    $charge = LeadCharge::first();
    expect($charge->charge_source)->toBe('rub');
    expect($charge->price_per_lead_kopecks)->toBe(50000);
});

it('writes supplier_lead_costs for each delivered deal copy (gap-fix)', function () {
    $ctx = prepareSharingFlow(2, [
        ['balance_leads' => 5, 'balance_rub' => '0.00', 'delivered_in_month' => 0],
        ['balance_leads' => 0, 'balance_rub' => '1000.00', 'delivered_in_month' => 0],
    ]);

    (new RouteSupplierLeadJob($ctx['lead']->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
    );

    $costs = DB::table('supplier_lead_costs')->get();
    expect($costs)->toHaveCount(2);
    foreach ($costs as $cost) {
        expect((int) $cost->supplier_id)->toBe($ctx['supplier']->id);
        expect((string) $cost->cost_rub)->toBe($ctx['supplier']->cost_rub);
    }
});

it('retry idempotency: повторный run job’а не дублирует lead_charges', function () {
    $ctx = prepareSharingFlow(1, [['balance_leads' => 5, 'balance_rub' => '0.00']]);
    $job = new RouteSupplierLeadJob($ctx['lead']->id);

    $job->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
    );
    $job->handle(  // повторный run — processed_at guard должен сработать
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
    );

    expect(LeadCharge::count())->toBe(1);
    expect(Deal::count())->toBe(1);
    expect((int) $ctx['tenants'][0]->fresh()->balance_leads)->toBe(4);
});
  • Step 2: Запустить тест — FAIL (LedgerService не инжектирован в Job)
cd app && ./vendor/bin/pest tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php

Expected: 4 FAIL — старый код пишет только balance_leads -1, не использует tier-lookup, lead_charges остаётся пустым.

  • Step 3: Модифицировать RouteSupplierLeadJob: добавить инжектируемый LedgerService

Открыть app/app/Jobs/RouteSupplierLeadJob.php, внести 3 правки:

3a. Добавить use-импорты после существующих (после строки 15):

use App\Exceptions\Billing\InsufficientBalanceException;
use App\Services\Billing\LedgerService;

3b. Расширить сигнатуру handle() — добавить параметр LedgerService $ledger (строка ~85):

public function handle(
    LeadRouter $router,
    SupplierProjectResolver $resolver,
    DuplicateDetector $duplicateDetector,
    NotificationService $notifier,
    LedgerService $ledger,
): void {

3c. Передать $ledger в createDealCopyForProject (строка ~111-114):

foreach ($matched as $project) {
    try {
        if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger)) {
            $createdCount++;
        }
    } catch (Throwable $e) {
        // ... существующий код
    }
}

3d. Расширить сигнатуру createDealCopyForProject (строка ~178):

private function createDealCopyForProject(
    SupplierLead $lead,
    Project $project,
    DuplicateDetector $duplicateDetector,
    NotificationService $notifier,
    LedgerService $ledger,
): bool {

3e. Заменить блок строк 265-279 на LedgerService::chargeForDelivery + try/catch:

Найти строки:

$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 {
    $ledger->chargeForDelivery($tenant, $deal, $lead);
} 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;  // bubble — DB::transaction rollback + поднимется в createDealCopyForProject уровень
}

$project->increment('delivered_today');
$project->increment('delivered_in_month');

Также удалить use App\Models\BalanceTransaction; если он стал ненужным (LedgerService теперь пишет BalanceTransaction внутри).

Внимание: InsufficientBalanceException пробрасывается из DB::transaction → ловится outside Closure'а, в createDealCopyForProject уровне. Сама структура transaction уже есть в строке 184 (return DB::transaction(function () use (...) {...});). Обернём вызов транзакции в try/catch на уровне createDealCopyForProject (это понадобится в Task 6 для auto-pause flow; пока — просто rethrow в parent handle()).

В Task 4 НЕ добавляем auto-pause логику — только rethrow InsufficientBalanceException наверх. Auto-pause = Task 6.

  • Step 4: Запустить тест — PASS (4 теста должны быть зелёными)
cd app && ./vendor/bin/pest tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php

Expected: 4 PASS.

  • Step 5: Pint + Larastan + полный Pest
cd app && composer pint && composer stan && ./vendor/bin/pest --parallel

Expected: pint clean, stan 0 above baseline. Если есть Larastan warnings про BalanceTransaction import — удалить.

Если падают существующие тесты RouteSupplierLeadJobTest, RouteSupplierLeadJobShareTest и т.п. (Plans 2/2.5) — нужно их подправить: они должны заранее засеять pricing_tiers (через PricingTierSeeder в beforeEach) и снабдить tenant'ы balance_leads >= matchCount ИЛИ balance_rub под tier-цену.

# Грепнуть, какие тесты используют RouteSupplierLeadJob и могут сломаться:
grep -rln "RouteSupplierLeadJob" app/tests/

Для каждого failing test — добавить в beforeEach:

(new \Database\Seeders\PricingTierSeeder())->run();

И в fixture-tenant'е установить достаточный balance_leads.

  • Step 6: Commit
git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
# + любые существующие test'ы, потребовавшие правок balance_leads / seeder в beforeEach:
# git add app/tests/Feature/Supplier/<existing-test>.php
git commit -m "feat(supplier): Plan 4 Task 4 — integrate LedgerService в RouteSupplierLeadJob (charge + supplier_lead_costs gap-fix)"

Task 5: ResetMonthlyCountersCommand + Schedule entry

Files:

  • Create: app/app/Console/Commands/ResetMonthlyCountersCommand.php

  • Modify: app/routes/console.php

  • Create: app/tests/Feature/Console/ResetMonthlyCountersCommandTest.php

  • Step 1: Написать failing тест

<?php
// app/tests/Feature/Console/ResetMonthlyCountersCommandTest.php

declare(strict_types=1);

use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;

uses(\Tests\TestCase::class, RefreshDatabase::class);

it('resets tenants.delivered_in_month and projects.delivered_in_month to 0', function () {
    $tenantA = Tenant::factory()->create(['delivered_in_month' => 100]);
    $tenantB = Tenant::factory()->create(['delivered_in_month' => 5]);
    $tenantC = Tenant::factory()->create(['delivered_in_month' => 0]);  // already 0

    Project::factory()->create(['tenant_id' => $tenantA->id, 'delivered_in_month' => 50]);
    Project::factory()->create(['tenant_id' => $tenantA->id, 'delivered_in_month' => 50]);
    Project::factory()->create(['tenant_id' => $tenantB->id, 'delivered_in_month' => 5]);

    Artisan::call('projects:reset-monthly');

    expect($tenantA->fresh()->delivered_in_month)->toBe(0);
    expect($tenantB->fresh()->delivered_in_month)->toBe(0);
    expect($tenantC->fresh()->delivered_in_month)->toBe(0);

    expect(Project::sum('delivered_in_month'))->toBe(0);
});

it('is idempotent — second run reports 0 affected', function () {
    $tenant = Tenant::factory()->create(['delivered_in_month' => 10]);
    Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_in_month' => 10]);

    Artisan::call('projects:reset-monthly');
    $secondOutput = Artisan::call('projects:reset-monthly');

    expect($secondOutput)->toBe(0);  // SUCCESS exit code
    expect(\Artisan::output())->toContain('0 tenants');
    expect(\Artisan::output())->toContain('0 projects');
});

it('Schedule entry registered for monthly on 1st 00:00 Europe/Moscow', function () {
    /** @var \Illuminate\Console\Scheduling\Schedule $schedule */
    $schedule = app(\Illuminate\Console\Scheduling\Schedule::class);

    $found = collect($schedule->events())->first(
        fn ($event) => str_contains($event->command ?? '', 'projects:reset-monthly')
    );

    expect($found)->not->toBeNull();
    expect($found->expression)->toBe('0 0 1 * *');  // 00:00 1-го числа каждого месяца
    expect($found->timezone)->toBe('Europe/Moscow');
});

it('uses pgsql_supplier BYPASSRLS connection (touches all tenants without SET LOCAL)', function () {
    Tenant::factory()->count(3)->create(['delivered_in_month' => 7]);

    // Без SET LOCAL app.current_tenant_id reset должен затронуть всех 3 tenant'ов.
    // Это тест на использование pgsql_supplier (BYPASSRLS), не default pgsql.
    Artisan::call('projects:reset-monthly');

    expect(Tenant::where('delivered_in_month', '>', 0)->count())->toBe(0);
});
  • Step 2: Запустить — FAIL
cd app && ./vendor/bin/pest tests/Feature/Console/ResetMonthlyCountersCommandTest.php

Expected: 4 FAIL ("Command 'projects:reset-monthly' is not defined").

  • Step 3: Реализовать ResetMonthlyCountersCommand
<?php
// app/app/Console/Commands/ResetMonthlyCountersCommand.php

declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

/**
 * Сброс tenants.delivered_in_month + projects.delivered_in_month = 0.
 *
 * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §4.1
 * Расписание: 1-го числа каждого месяца в 00:00 Europe/Moscow.
 *
 * Идёт через connection `pgsql_supplier` (BYPASSRLS-роль crm_supplier_worker) —
 * паттерн ResetDeliveredTodayCommand. Один statement на таблицу, без SET LOCAL.
 */
class ResetMonthlyCountersCommand extends Command
{
    protected $signature = 'projects:reset-monthly';

    protected $description = 'Сброс tenants.delivered_in_month + projects.delivered_in_month = 0 (1-го числа в 00:00 МСК, Plan 4)';

    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;
    }
}
  • Step 4: Добавить Schedule entry в routes/console.php

Открыть app/routes/console.php, после существующего Schedule::command('projects:reset-delivered-today') (строка ~21-23) добавить:

// Plan 4: monthly reset 1-го числа в 00:00 МСК для tier-lookup в LedgerService.
Schedule::command('projects:reset-monthly')
    ->monthlyOn(1, '00:00')
    ->timezone('Europe/Moscow');
  • Step 5: Запустить — PASS
cd app && ./vendor/bin/pest tests/Feature/Console/ResetMonthlyCountersCommandTest.php

Expected: 4 PASS.

  • Step 6: Pint + Larastan + полный Pest
cd app && composer pint && composer stan && ./vendor/bin/pest --parallel

Expected: clean.

  • Step 7: Commit
git add app/app/Console/Commands/ResetMonthlyCountersCommand.php \
        app/routes/console.php \
        app/tests/Feature/Console/ResetMonthlyCountersCommandTest.php
git commit -m "feat(commands): Plan 4 Task 5 — ResetMonthlyCountersCommand + Schedule monthlyOn(1, 00:00) МСК"

Task 6: Auto-pause flow + ZeroBalancePausedMail + rate-limit

Files:

  • Create: app/app/Mail/ZeroBalancePausedMail.php

  • Create: app/resources/views/emails/zero_balance_paused.blade.php

  • Modify: app/app/Services/NotificationService.php

  • Modify: app/app/Jobs/RouteSupplierLeadJob.php (+handleInsufficientBalance private method + try/catch в createDealCopyForProject)

  • Create: app/tests/Feature/Supplier/AutoPauseFlowTest.php

  • Step 1: Написать failing test для auto-pause flow

<?php
// app/tests/Feature/Supplier/AutoPauseFlowTest.php

declare(strict_types=1);

use App\Mail\ZeroBalancePausedMail;
use App\Models\Project;
use App\Models\Supplier;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Jobs\RouteSupplierLeadJob;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;

uses(\Tests\TestCase::class, RefreshDatabase::class);

beforeEach(function () {
    Mail::fake();
    Cache::store('redis')->flush();
    (new \Database\Seeders\PricingTierSeeder())->run();
});

function makeFlowWithBalance(array $balance): array
{
    $supplier = Supplier::where('code', 'b1')->first();
    $supplierProject = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com',
        'supplier_id' => $supplier->id,
    ]);
    $tenant = Tenant::factory()->create($balance);
    $project = Project::factory()->create([
        'tenant_id' => $tenant->id,
        'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $supplierProject->id,
        'is_active' => true, 'daily_limit_target' => 10,
        'effective_daily_limit_today' => 10, 'delivered_today' => 0,
        'delivery_days_mask' => 127, 'region_mask' => 255,
    ]);
    $lead = SupplierLead::factory()->create([
        'vid' => 'vid-pause-'.uniqid(),
        'phone' => '79991234567',
        'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234567', 'time' => time()],
        'supplier_project_id' => $supplierProject->id,
        'received_at' => now(),
    ]);
    return compact('tenant', 'project', 'lead');
}

it('pauses project (is_active=false) when both balances empty', function () {
    $ctx = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']);

    (new RouteSupplierLeadJob($ctx['lead']->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
        app(\App\Services\Billing\LedgerService::class),
    );

    expect($ctx['project']->fresh()->is_active)->toBeFalse();
});

it('sends ZeroBalancePausedMail на email tenant’а', function () {
    $ctx = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']);

    (new RouteSupplierLeadJob($ctx['lead']->id))->handle(
        app(\App\Services\LeadRouter::class),
        app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class),
        app(\App\Services\NotificationService::class),
        app(\App\Services\Billing\LedgerService::class),
    );

    Mail::assertSent(ZeroBalancePausedMail::class, function ($mail) use ($ctx) {
        return $mail->hasTo($ctx['tenant']->contact_email);
    });
});

it('respects rate-limit 1 hour per tenant: 2 consecutive calls → 1 email only', function () {
    $ctx1 = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']);
    $tenantId = $ctx1['tenant']->id;

    (new RouteSupplierLeadJob($ctx1['lead']->id))->handle(
        app(\App\Services\LeadRouter::class), app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class), app(\App\Services\NotificationService::class),
        app(\App\Services\Billing\LedgerService::class),
    );

    // Создаём второй lead для того же tenant'а (но другой project / vid)
    $ctx2lead = SupplierLead::factory()->create([
        'vid' => 'vid-pause-2-'.uniqid(),
        'phone' => '79991234568',
        'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234568', 'time' => time()],
        'supplier_project_id' => $ctx1['lead']->supplier_project_id,
        'received_at' => now(),
    ]);

    (new RouteSupplierLeadJob($ctx2lead->id))->handle(
        app(\App\Services\LeadRouter::class), app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class), app(\App\Services\NotificationService::class),
        app(\App\Services\Billing\LedgerService::class),
    );

    Mail::assertSent(ZeroBalancePausedMail::class, 1);
});

it('sends 2nd email after 65 minutes (rate-limit expired)', function () {
    $ctx = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']);

    (new RouteSupplierLeadJob($ctx['lead']->id))->handle(
        app(\App\Services\LeadRouter::class), app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class), app(\App\Services\NotificationService::class),
        app(\App\Services\Billing\LedgerService::class),
    );

    Mail::assertSent(ZeroBalancePausedMail::class, 1);

    \Illuminate\Support\Carbon::setTestNow(now()->addMinutes(65));
    Cache::store('redis')->flush();  // имитируем TTL expiry; в реальности Redis сам забудет через 1ч

    $lead2 = SupplierLead::factory()->create([
        'vid' => 'vid-pause-future-'.uniqid(),
        'phone' => '79991234569',
        'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234569', 'time' => time()],
        'supplier_project_id' => $ctx['lead']->supplier_project_id,
        'received_at' => now(),
    ]);

    (new RouteSupplierLeadJob($lead2->id))->handle(
        app(\App\Services\LeadRouter::class), app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class), app(\App\Services\NotificationService::class),
        app(\App\Services\Billing\LedgerService::class),
    );

    Mail::assertSent(ZeroBalancePausedMail::class, 2);
});

it('sharing-flow isolation: tenant A on zero paused, tenant B with balance receives deal', function () {
    $supplier = Supplier::where('code', 'b1')->first();
    $supplierProject = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com',
        'supplier_id' => $supplier->id,
    ]);
    $tenantA = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '0.00']);
    $tenantB = Tenant::factory()->create(['balance_leads' => 5, 'balance_rub' => '0.00']);
    $projectA = Project::factory()->create([
        'tenant_id' => $tenantA->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true,
        'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
        'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
    ]);
    $projectB = Project::factory()->create([
        'tenant_id' => $tenantB->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true,
        'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
        'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
    ]);
    $lead = SupplierLead::factory()->create([
        'vid' => 'vid-shared-'.uniqid(),
        'phone' => '79991234567',
        'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234567', 'time' => time()],
        'supplier_project_id' => $supplierProject->id,
        'received_at' => now(),
    ]);

    (new RouteSupplierLeadJob($lead->id))->handle(
        app(\App\Services\LeadRouter::class), app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
        app(\App\Services\DuplicateDetector::class), app(\App\Services\NotificationService::class),
        app(\App\Services\Billing\LedgerService::class),
    );

    expect($projectA->fresh()->is_active)->toBeFalse();
    expect($projectB->fresh()->is_active)->toBeTrue();
    expect((int) $tenantB->fresh()->balance_leads)->toBe(4);
});
  • Step 2: Запустить — FAIL (ZeroBalancePausedMail класс не существует, нет handleInsufficientBalance)
cd app && ./vendor/bin/pest tests/Feature/Supplier/AutoPauseFlowTest.php

Expected: 5 FAIL.

  • Step 3: Создать ZeroBalancePausedMail mailable
<?php
// app/app/Mail/ZeroBalancePausedMail.php

declare(strict_types=1);

namespace App\Mail;

use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

/**
 * Email клиенту Лидерры о приостановке проекта из-за недостаточного баланса.
 *
 * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §4.4
 */
final class ZeroBalancePausedMail extends Mailable
{
    use Queueable;
    use SerializesModels;

    public function __construct(
        public readonly Tenant $tenant,
        public readonly Project $project,
        public readonly int $requiredPriceKopecks,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "Проект «{$this->project->name}» приостановлен — недостаточно средств",
            to: [$this->tenant->contact_email],
        );
    }

    public function content(): Content
    {
        return new Content(view: 'emails.zero_balance_paused');
    }
}
  • Step 4: Создать Blade-шаблон
{{-- app/resources/views/emails/zero_balance_paused.blade.php --}}
<!DOCTYPE html>
<html lang="ru">
<head><meta charset="UTF-8"><title>Проект приостановлен</title></head>
<body style="font-family: 'Inter', Arial, sans-serif; color: #012019;">
  <p>Здравствуйте!</p>
  <p>Проект <strong>«{{ $project->name }}»</strong> приостановлен — недостаточно средств для приёма следующего лида.</p>
  <ul>
    <li>Баланс в лидах: <strong>{{ $tenant->balance_leads }}</strong></li>
    <li>Баланс в рублях: <strong>{{ number_format((float) $tenant->balance_rub, 2, ',', ' ') }} ₽</strong></li>
    <li>Цена за следующий лид: <strong>{{ number_format($requiredPriceKopecks / 100, 2, ',', ' ') }} ₽</strong></li>
  </ul>
  <p>Пополните баланс на странице <a href="https://{{ $tenant->subdomain }}.liderra.ru/billing">«Баланс и тарифы»</a>, чтобы возобновить приём лидов.</p>
  <p>С уважением, команда Лидерра.</p>
</body>
</html>
  • Step 5: Добавить notifyZeroBalance в NotificationService

Открыть app/app/Services/NotificationService.php. После метода notifyNewLead (грепнуть имя метода для exact position) добавить:

use App\Mail\ZeroBalancePausedMail;
use Illuminate\Support\Facades\Mail;

// ...

public function notifyZeroBalance(Tenant $tenant, Project $project, int $requiredPriceKopecks): void
{
    Mail::to($tenant->contact_email)->send(
        new ZeroBalancePausedMail($tenant, $project, $requiredPriceKopecks)
    );
}
  • Step 6: Добавить handleInsufficientBalance в RouteSupplierLeadJob

Открыть app/app/Jobs/RouteSupplierLeadJob.php:

6a. Добавить use-импорты (после Task 4 импортов):

use Illuminate\Support\Facades\Cache;

6b. Заменить try/catch в createDealCopyForProject (Task 4) на полный flow:

Найти структуру try { ... DB::transaction(function (...) { ... }); } catch (InsufficientBalanceException $e) { ... } (Task 4 ввёл это). Сейчас Task 6 расширяет catch:

try {
    return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger): bool {
        // ... весь существующий код createDealCopyForProject (Tenant lock, Project lock, Deal create,
        //     DuplicateDetector, $ledger->chargeForDelivery, increment delivered_today/in_month,
        //     ActivityLog, notifyNewLead) ...
    });
} catch (InsufficientBalanceException $e) {
    // Транзакция rolled back — Deal не создан, balance не тронут.
    $this->handleInsufficientBalance($lead, $project, $e);
    return false;
}

6c. Добавить приватный метод handleInsufficientBalance в конец класса (перед failed()):

private function handleInsufficientBalance(
    SupplierLead $lead,
    Project $project,
    InsufficientBalanceException $e,
): void {
    // 1) UPDATE projects.is_active=false через pgsql_supplier (BYPASSRLS — паттерн ResetCmd)
    DB::connection(self::DB_CONNECTION)
        ->update('UPDATE projects SET is_active = false WHERE id = ?', [$project->id]);

    // 2) Email-алерт с rate-limit 1/час/tenant через Redis SETNX.
    $cacheKey = "billing:zero_balance_alert:{$project->tenant_id}";
    if (Cache::store('redis')->add($cacheKey, true, now()->addHour())) {
        $project->loadMissing('tenant');
        app(NotificationService::class)->notifyZeroBalance(
            $project->tenant, $project, $e->priceKopecks
        );
    }

    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,
    ]);
}
  • Step 7: Запустить test — PASS
cd app && ./vendor/bin/pest tests/Feature/Supplier/AutoPauseFlowTest.php

Expected: 5 PASS.

  • Step 8: Pint + Larastan + полный Pest
cd app && composer pint && composer stan && ./vendor/bin/pest --parallel
  • Step 9: Commit
git add app/app/Mail/ZeroBalancePausedMail.php \
        app/resources/views/emails/zero_balance_paused.blade.php \
        app/app/Services/NotificationService.php \
        app/app/Jobs/RouteSupplierLeadJob.php \
        app/tests/Feature/Supplier/AutoPauseFlowTest.php
git commit -m "feat(billing): Plan 4 Task 6 — auto-pause flow + ZeroBalancePausedMail + 1/hour rate-limit"

Task 7: SupplierCsvParser + SupplierPortalClient::downloadLeadsCsv

Files:

  • Create: app/app/Services/Supplier/SupplierCsvParser.php

  • Create: app/tests/Unit/Supplier/SupplierCsvParserTest.php

  • Modify: app/app/Services/Supplier/SupplierPortalClient.php

  • Create: app/tests/Unit/Supplier/SupplierPortalClientCsvTest.php

  • Step 1: Написать failing unit-test для SupplierCsvParser

<?php
// app/tests/Unit/Supplier/SupplierCsvParserTest.php

declare(strict_types=1);

use App\Services\Supplier\SupplierCsvParser;

uses(\Tests\TestCase::class);

beforeEach(function () {
    $this->parser = new SupplierCsvParser();
});

it('parses empty CSV → yields nothing', function () {
    $rows = iterator_to_array($this->parser->parse(''));
    expect($rows)->toBeEmpty();
});

it('parses 1 row → yields 1 struct with vid/project/phone/time', function () {
    $csv = "vid;project;tag;phone;phones;time\n"
         . "1234;B1_example.com;;79991234567;79991234567;1715432400\n";

    $rows = iterator_to_array($this->parser->parse($csv));

    expect($rows)->toHaveCount(1);
    expect($rows[0])->toMatchArray([
        'vid' => '1234',
        'project' => 'B1_example.com',
        'phone' => '79991234567',
        'time' => 1715432400,
    ]);
});

it('parses 1000 rows without OOM (streaming generator)', function () {
    $lines = ["vid;project;tag;phone;phones;time"];
    for ($i = 1; $i <= 1000; $i++) {
        $lines[] = "{$i};B1_test.com;;7999000{$i};7999000{$i};1715432400";
    }
    $csv = implode("\n", $lines)."\n";

    $count = 0;
    foreach ($this->parser->parse($csv) as $_) {
        $count++;
    }

    expect($count)->toBe(1000);
});

it('skips malformed rows with missing columns + logs warning', function () {
    \Illuminate\Support\Facades\Log::spy();

    $csv = "vid;project;tag;phone;phones;time\n"
         . "1234;B1_example.com;;79991234567;79991234567;1715432400\n"
         . "broken-row-only-one-column\n"
         . "5678;B1_another.com;;79991234567;79991234567;1715432500\n";

    $rows = iterator_to_array($this->parser->parse($csv));

    expect($rows)->toHaveCount(2);
    expect($rows[0]['vid'])->toBe('1234');
    expect($rows[1]['vid'])->toBe('5678');

    \Illuminate\Support\Facades\Log::shouldHaveReceived('warning')
        ->with('supplier_csv_parser.malformed_row', \Mockery::any())
        ->once();
});

it('handles BOM + CRLF line endings', function () {
    $bom = "\xEF\xBB\xBF";
    $csv = $bom . "vid;project;tag;phone;phones;time\r\n"
         . "1234;B1_example.com;;79991234567;79991234567;1715432400\r\n";

    $rows = iterator_to_array($this->parser->parse($csv));

    expect($rows)->toHaveCount(1);
    expect($rows[0]['vid'])->toBe('1234');
});
  • Step 2: Запустить — FAIL
cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierCsvParserTest.php

Expected: 5 FAIL ("Class SupplierCsvParser does not exist").

  • Step 3: Реализовать SupplierCsvParser
<?php
// app/app/Services/Supplier/SupplierCsvParser.php

declare(strict_types=1);

namespace App\Services\Supplier;

use Illuminate\Support\Facades\Log;

/**
 * Streaming-парсер CSV-экспорта `/admin/report/index?type=49` поставщика.
 *
 * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.2
 * Ожидаемые столбцы: vid;project;tag;phone;phones;time (placeholder; уточнится
 * после Plan 3 Tasks 1-2 discovery с credentials поставщика).
 *
 * Возвращает Generator — вызывающий (CsvReconcileJob) сам решает, сколько
 * копить в памяти. BOM + CRLF поддерживаются. Malformed rows skip + log.
 */
final class SupplierCsvParser
{
    private const EXPECTED_COLUMNS = 6;

    /**
     * @return iterable<int, array{vid: string, project: string, phone: string, time: int}>
     */
    public function parse(string $rawCsv): iterable
    {
        if ($rawCsv === '') {
            return;
        }

        // Убираем BOM (UTF-8 BOM = EF BB BF)
        if (str_starts_with($rawCsv, "\xEF\xBB\xBF")) {
            $rawCsv = substr($rawCsv, 3);
        }

        // Нормализуем CRLF → LF
        $rawCsv = str_replace("\r\n", "\n", $rawCsv);

        $lines = explode("\n", $rawCsv);
        $headerSkipped = false;
        $lineNo = 0;

        foreach ($lines as $line) {
            $lineNo++;
            if ($line === '') {
                continue;
            }
            if (! $headerSkipped) {
                $headerSkipped = true;
                continue;
            }

            $cols = str_getcsv($line, separator: ';');
            if (count($cols) < self::EXPECTED_COLUMNS) {
                Log::warning('supplier_csv_parser.malformed_row', [
                    'line_no' => $lineNo,
                    'columns_found' => count($cols),
                    'expected' => self::EXPECTED_COLUMNS,
                    'sample' => substr($line, 0, 100),
                ]);
                continue;
            }

            yield [
                'vid' => (string) $cols[0],
                'project' => (string) $cols[1],
                'phone' => (string) $cols[3],
                'time' => (int) $cols[5],
            ];
        }
    }
}
  • Step 4: Запустить — PASS
cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierCsvParserTest.php

Expected: 5 PASS.

  • Step 5: Написать failing test для downloadLeadsCsv
<?php
// app/tests/Unit/Supplier/SupplierPortalClientCsvTest.php

declare(strict_types=1);

use App\Exceptions\Supplier\SupplierTransientException;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

uses(\Tests\TestCase::class);

beforeEach(function () {
    Cache::store('redis')->put('supplier:session', [
        'phpsessid' => 'test-session', 'csrf' => 'test-csrf-token',
    ], now()->addHour());

    config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});

it('GET /admin/report/index?type=49 returns CSV body on 200', function () {
    Http::fake([
        'crm.bp-gr.ru/admin/report/index*' => Http::response(
            "vid;project;tag;phone;phones;time\n1234;B1_example.com;;79991234567;79991234567;1715432400\n",
            200,
            ['Content-Type' => 'text/csv'],
        ),
    ]);

    $client = new SupplierPortalClient(app(HttpFactory::class));
    $body = $client->downloadLeadsCsv(
        \Illuminate\Support\Carbon::parse('2024-05-11 00:00:00'),
        \Illuminate\Support\Carbon::parse('2024-05-12 00:00:00'),
    );

    expect($body)->toContain('1234;B1_example.com');
});

it('401 → triggers session refresh → retry → 200', function () {
    Http::fakeSequence('crm.bp-gr.ru/admin/report/index*')
        ->push('Unauthorized', 401)
        ->push("vid;...\n", 200);

    // RefreshSupplierSessionJob — мокаем через app->bind()
    app()->bind(\App\Jobs\Supplier\RefreshSupplierSessionJob::class, function () {
        return new class {
            public function handle(): void {
                Cache::store('redis')->put('supplier:session', [
                    'phpsessid' => 'refreshed', 'csrf' => 'refreshed-csrf',
                ], now()->addHour());
            }
        };
    });

    $client = new SupplierPortalClient(app(HttpFactory::class));
    $body = $client->downloadLeadsCsv(
        \Illuminate\Support\Carbon::parse('2024-05-11'),
        \Illuminate\Support\Carbon::parse('2024-05-12'),
    );

    expect($body)->toContain('vid');
});

it('500 → SupplierTransientException', function () {
    Http::fake(['crm.bp-gr.ru/*' => Http::response('Internal Server Error', 500)]);

    $client = new SupplierPortalClient(app(HttpFactory::class));

    expect(fn () => $client->downloadLeadsCsv(
        \Illuminate\Support\Carbon::parse('2024-05-11'),
        \Illuminate\Support\Carbon::parse('2024-05-12'),
    ))->toThrow(SupplierTransientException::class);
});
  • Step 6: Запустить — FAIL ("method does not exist")
cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientCsvTest.php
  • Step 7: Добавить downloadLeadsCsv в SupplierPortalClient

Открыть app/app/Services/Supplier/SupplierPortalClient.php, добавить use:

use Carbon\CarbonInterface;

После метода deleteProject (~строка 65) добавить:

/**
 * GET /admin/report/index?type=49 — CSV-экспорт лидов за окно [from, to].
 * Auth/retry семантика наследуется от request() (PHPSESSID + X-CSRF-Token +
 * 401 → RefreshSession + 5xx → SupplierTransientException + 4xx → SupplierClientException).
 *
 * Возвращает raw CSV-body (UTF-8 + BOM, CRLF). Парсинг — снаружи через
 * SupplierCsvParser (streaming через generator).
 *
 * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.1
 */
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();
}
  • Step 8: Запустить test — PASS
cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientCsvTest.php

Expected: 3 PASS.

  • Step 9: Pint + Larastan + полный Pest
cd app && composer pint && composer stan && ./vendor/bin/pest --parallel
  • Step 10: Commit
git add app/app/Services/Supplier/SupplierCsvParser.php \
        app/app/Services/Supplier/SupplierPortalClient.php \
        app/tests/Unit/Supplier/SupplierCsvParserTest.php \
        app/tests/Unit/Supplier/SupplierPortalClientCsvTest.php
git commit -m "feat(supplier): Plan 4 Task 7 — SupplierCsvParser (streaming) + SupplierPortalClient::downloadLeadsCsv"

Task 8: CsvReconcileJob + CsvDriftAlertMail + Schedule entry

Files:

  • Create: app/app/Jobs/Supplier/CsvReconcileJob.php

  • Create: app/app/Mail/CsvDriftAlertMail.php

  • Create: app/resources/views/emails/csv_drift_alert.blade.php

  • Modify: app/routes/console.php

  • Modify: app/config/services.php (если ключ supplier.alert_email не существует — добавим)

  • Create: app/tests/Feature/Supplier/CsvReconcileJobTest.php

  • Step 1: Проверить ключ services.supplier.alert_email существует

grep -n "alert_email\|supplier" app/config/services.php

Если нет — добавить блок:

// app/config/services.php — добавить в return array
'supplier' => [
    'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'),
    'portal_url'  => env('SUPPLIER_PORTAL_URL', 'https://crm.bp-gr.ru'),
    // ... possibly other keys из Plan 3
],
  • Step 2: Написать failing integration-test для CsvReconcileJob
<?php
// app/tests/Feature/Supplier/CsvReconcileJobTest.php

declare(strict_types=1);

use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\RouteSupplierLeadJob;
use App\Mail\CsvDriftAlertMail;
use App\Models\Supplier;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;

uses(\Tests\TestCase::class, RefreshDatabase::class);

beforeEach(function () {
    Mail::fake();
    Bus::fake();
    Cache::store('redis')->flush();
    Cache::store('redis')->put('supplier:session', [
        'phpsessid' => 'test', 'csrf' => 'test',
    ], now()->addHour());
    config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
    config(['services.supplier.alert_email' => 'ops@liderra.ru']);
});

function csvBody(array $rows): string
{
    $out = "vid;project;tag;phone;phones;time\n";
    foreach ($rows as $r) {
        $out .= "{$r['vid']};{$r['project']};;{$r['phone']};{$r['phone']};{$r['time']}\n";
    }
    return $out;
}

it('matches existing leads, no missing — status=ok, no alert', function () {
    $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'a.com']);
    $now = time();

    for ($i = 0; $i < 10; $i++) {
        SupplierLead::factory()->create([
            'vid' => "vid-{$i}",
            'phone' => "799900000{$i}",
            'supplier_project_id' => $sp->id,
            'received_at' => now()->subHour(),
        ]);
    }

    $rows = [];
    for ($i = 0; $i < 10; $i++) {
        $rows[] = ['vid' => "vid-{$i}", 'project' => 'B1_a.com', 'phone' => "799900000{$i}", 'time' => $now - 3600];
    }
    Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]);

    app(CsvReconcileJob::class)->handle(
        app(\App\Services\Supplier\SupplierPortalClient::class),
        app(\App\Services\Supplier\SupplierCsvParser::class),
        app(\Illuminate\Contracts\Mail\Mailer::class),
    );

    $log = DB::table('supplier_csv_reconcile_log')->first();
    expect($log->status)->toBe('ok');
    expect((int) $log->total_csv_rows)->toBe(10);
    expect((int) $log->matched_count)->toBe(10);
    expect((int) $log->recovered_count)->toBe(0);

    Mail::assertNothingSent();
    Bus::assertNothingDispatched();
});

it('drift 10% (1 missing of 10) → alert email + 1 RouteJob dispatched', function () {
    $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'a.com']);
    $now = time();

    for ($i = 0; $i < 9; $i++) {  // 9 existing
        SupplierLead::factory()->create([
            'vid' => "vid-{$i}", 'phone' => "799900000{$i}",
            'supplier_project_id' => $sp->id, 'received_at' => now()->subHour(),
        ]);
    }

    $rows = [];
    for ($i = 0; $i < 10; $i++) {  // 10 в CSV — 1 missing (vid-9)
        $rows[] = ['vid' => "vid-{$i}", 'project' => 'B1_a.com', 'phone' => "799900000{$i}", 'time' => $now - 3600];
    }
    Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]);

    app(CsvReconcileJob::class)->handle(
        app(\App\Services\Supplier\SupplierPortalClient::class),
        app(\App\Services\Supplier\SupplierCsvParser::class),
        app(\Illuminate\Contracts\Mail\Mailer::class),
    );

    $log = DB::table('supplier_csv_reconcile_log')->first();
    expect($log->status)->toBe('drift_alert');
    expect((float) $log->drift_ratio)->toBeGreaterThan(0.05);
    expect((int) $log->recovered_count)->toBe(1);

    Mail::assertSent(CsvDriftAlertMail::class, 1);
    Bus::assertDispatched(RouteSupplierLeadJob::class, 1);
});

it('drift 1% (1 missing of 100) → status=ok, no alert', function () {
    $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'a.com']);
    $now = time();

    for ($i = 0; $i < 99; $i++) {
        SupplierLead::factory()->create([
            'vid' => "vid-{$i}", 'phone' => "799900{$i}",
            'supplier_project_id' => $sp->id, 'received_at' => now()->subHour(),
        ]);
    }

    $rows = [];
    for ($i = 0; $i < 100; $i++) {
        $rows[] = ['vid' => "vid-{$i}", 'project' => 'B1_a.com', 'phone' => "799900{$i}", 'time' => $now - 3600];
    }
    Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]);

    app(CsvReconcileJob::class)->handle(
        app(\App\Services\Supplier\SupplierPortalClient::class),
        app(\App\Services\Supplier\SupplierCsvParser::class),
        app(\Illuminate\Contracts\Mail\Mailer::class),
    );

    $log = DB::table('supplier_csv_reconcile_log')->first();
    expect($log->status)->toBe('ok');
    expect((int) $log->recovered_count)->toBe(1);
    Mail::assertNothingSent();
});

it('empty CSV → status=ok, drift=0, no alert', function () {
    Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response("vid;project;tag;phone;phones;time\n", 200)]);

    app(CsvReconcileJob::class)->handle(
        app(\App\Services\Supplier\SupplierPortalClient::class),
        app(\App\Services\Supplier\SupplierCsvParser::class),
        app(\Illuminate\Contracts\Mail\Mailer::class),
    );

    $log = DB::table('supplier_csv_reconcile_log')->first();
    expect($log->status)->toBe('ok');
    expect((int) $log->total_csv_rows)->toBe(0);
});

it('SupplierTransientException → status=failed, error_message recorded', function () {
    Http::fake(['crm.bp-gr.ru/*' => Http::response('Server Error', 500)]);

    expect(fn () =>
        app(CsvReconcileJob::class)->handle(
            app(\App\Services\Supplier\SupplierPortalClient::class),
            app(\App\Services\Supplier\SupplierCsvParser::class),
            app(\Illuminate\Contracts\Mail\Mailer::class),
        )
    )->toThrow(\App\Exceptions\Supplier\SupplierTransientException::class);

    $log = DB::table('supplier_csv_reconcile_log')->first();
    expect($log->status)->toBe('failed');
    expect($log->error_message)->toContain('500');
});

it('Schedule entry: hourly cron registered', function () {
    /** @var \Illuminate\Console\Scheduling\Schedule $schedule */
    $schedule = app(\Illuminate\Console\Scheduling\Schedule::class);

    $events = $schedule->events();
    $hasCsv = collect($events)->contains(fn ($event) =>
        str_contains((string) $event->description, 'CsvReconcileJob')
        || str_contains((string) $event->description, 'csv-reconcile')
    );
    expect($hasCsv)->toBeTrue();
});
  • Step 3: Запустить — FAIL
cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvReconcileJobTest.php

Expected: 6 FAIL.

  • Step 4: Создать CsvDriftAlertMail
<?php
// app/app/Mail/CsvDriftAlertMail.php

declare(strict_types=1);

namespace App\Mail;

use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

/**
 * Email алерт админу Лидерры о расхождении CSV-сверки > 5%.
 *
 * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.6
 */
final class CsvDriftAlertMail extends Mailable
{
    use Queueable;
    use SerializesModels;

    public function __construct(
        public readonly int $reconcileLogId,
        public readonly int $totalCsvRows,
        public readonly int $missingCount,
        public readonly int $recoveredCount,
        public readonly float $driftRatio,
        public readonly CarbonInterface $windowStart,
        public readonly CarbonInterface $windowEnd,
    ) {}

    public function envelope(): Envelope
    {
        $pct = number_format($this->driftRatio * 100, 2, ',', ' ');
        $window = $this->windowStart->format('Y-m-d H:i').' — '.$this->windowEnd->format('Y-m-d H:i');
        return new Envelope(
            subject: "Лидерра ↔ Поставщик: расхождение CSV > 5% за {$window} ({$pct}%)",
        );
    }

    public function content(): Content
    {
        return new Content(view: 'emails.csv_drift_alert');
    }
}
  • Step 5: Создать Blade-шаблон
{{-- app/resources/views/emails/csv_drift_alert.blade.php --}}
<!DOCTYPE html>
<html lang="ru">
<head><meta charset="UTF-8"><title>CSV drift alert</title></head>
<body style="font-family: Arial, sans-serif;">
  <h3>Расхождение CSV-сверки с базой Лидерры</h3>
  <p>Окно: <strong>{{ $windowStart->format('Y-m-d H:i') }} — {{ $windowEnd->format('Y-m-d H:i') }}</strong></p>
  <ul>
    <li>Всего строк в CSV: <strong>{{ $totalCsvRows }}</strong></li>
    <li>Пропущено webhook'ом (recovered): <strong>{{ $missingCount }}</strong></li>
    <li>Восстановлено в БД: <strong>{{ $recoveredCount }}</strong></li>
    <li>Drift ratio: <strong>{{ number_format($driftRatio * 100, 2, ',', ' ') }}%</strong> (порог 5%)</li>
  </ul>
  <p>Подробности — в таблице <code>supplier_csv_reconcile_log.id = {{ $reconcileLogId }}</code>.</p>
</body>
</html>
  • Step 6: Создать CsvReconcileJob
<?php
// app/app/Jobs/Supplier/CsvReconcileJob.php

declare(strict_types=1);

namespace App\Jobs\Supplier;

use App\Jobs\RouteSupplierLeadJob;
use App\Mail\CsvDriftAlertMail;
use App\Models\SupplierLead;
use App\Services\Supplier\SupplierCsvParser;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Hourly CSV reconciliation с порталом поставщика.
 *
 * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
 *
 * Алгоритм:
 *   1. Cache::lock на 600s — overlap-защита.
 *   2. INSERT supplier_csv_reconcile_log (status='running').
 *   3. Download CSV за окно 25h.
 *   4. Parse → собираем ['vid' => row].
 *   5. SELECT existing vid'ы из supplier_leads (BYPASSRLS).
 *   6. Diff = missing.
 *   7. Для каждой missing — INSERT supplier_leads (recovered_from_csv_at) + dispatch RouteJob.
 *   8. UPDATE log с метриками + status.
 *   9. drift > 5% → CsvDriftAlertMail + alert_email_sent_at.
 *  10. На exception — status='failed', throw.
 */
final class CsvReconcileJob implements ShouldQueue
{
    use FoundationQueueable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $tries = 1;
    public int $timeout = 300;

    private const DB_CONNECTION = 'pgsql_supplier';
    private const DRIFT_THRESHOLD = 0.05;
    private const WINDOW_HOURS = 25;
    private const LOCK_NAME = 'supplier:csv_reconcile';
    private const LOCK_TTL_SECONDS = 600;

    public function handle(
        SupplierPortalClient $portal,
        SupplierCsvParser $parser,
        Mailer $mailer,
    ): void {
        $lock = Cache::store('redis')->lock(self::LOCK_NAME, self::LOCK_TTL_SECONDS);
        if (! $lock->get()) {
            Log::info('csv_reconcile.skipped_overlap');
            return;
        }

        $windowEnd = Carbon::now();
        $windowStart = (clone $windowEnd)->subHours(self::WINDOW_HOURS);

        $logId = DB::connection(self::DB_CONNECTION)
            ->table('supplier_csv_reconcile_log')
            ->insertGetId([
                'started_at' => now(), 'window_start' => $windowStart, 'window_end' => $windowEnd,
                'status' => 'running', 'created_at' => now(),
            ]);

        try {
            $csv = $portal->downloadLeadsCsv($windowStart, $windowEnd);

            /** @var array<string, array<string, mixed>> $csvByVid */
            $csvByVid = [];
            foreach ($parser->parse($csv) as $row) {
                $csvByVid[$row['vid']] = $row;
            }
            $totalCsvRows = count($csvByVid);

            $existing = DB::connection(self::DB_CONNECTION)
                ->table('supplier_leads')
                ->where('received_at', '>=', $windowStart)
                ->where('received_at', '<', $windowEnd->copy()->addHour())
                ->pluck('vid')
                ->all();

            $existingMap = array_flip($existing);
            $missing = array_diff_key($csvByVid, $existingMap);

            $recoveredCount = 0;
            foreach ($missing as $vid => $row) {
                try {
                    $lead = SupplierLead::create([
                        'vid' => $vid,
                        'phone' => (string) $row['phone'],
                        'raw_payload' => $row,
                        'received_at' => Carbon::createFromTimestamp((int) $row['time']),
                        'recovered_from_csv_at' => now(),
                        'supplier_project_id' => null,  // ResolverStub зарезолвит при RouteJob run
                    ]);
                    RouteSupplierLeadJob::dispatch($lead->id);
                    $recoveredCount++;
                } catch (\Illuminate\Database\QueryException $e) {
                    if (str_contains($e->getMessage(), 'unique')) {
                        Log::info('csv_reconcile.duplicate_vid_skipped', ['vid' => $vid]);
                        continue;
                    }
                    throw $e;
                }
            }

            $matchedCount = $totalCsvRows - count($missing);
            $driftRatio = $totalCsvRows > 0 ? count($missing) / $totalCsvRows : 0.0;
            $status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok';

            $update = [
                'finished_at' => now(), 'total_csv_rows' => $totalCsvRows,
                'matched_count' => $matchedCount, 'recovered_count' => $recoveredCount,
                'drift_ratio' => $driftRatio, 'status' => $status,
            ];

            if ($status === 'drift_alert') {
                $mailer->to((string) config('services.supplier.alert_email'))
                    ->send(new CsvDriftAlertMail(
                        reconcileLogId: $logId,
                        totalCsvRows: $totalCsvRows,
                        missingCount: count($missing),
                        recoveredCount: $recoveredCount,
                        driftRatio: $driftRatio,
                        windowStart: $windowStart,
                        windowEnd: $windowEnd,
                    ));
                $update['alert_email_sent_at'] = now();
            }

            DB::connection(self::DB_CONNECTION)
                ->table('supplier_csv_reconcile_log')->where('id', $logId)->update($update);

        } catch (Throwable $e) {
            DB::connection(self::DB_CONNECTION)
                ->table('supplier_csv_reconcile_log')->where('id', $logId)->update([
                    'finished_at' => now(),
                    'status' => 'failed',
                    'error_message' => substr($e->getMessage(), 0, 1000),
                ]);
            throw $e;
        } finally {
            $lock->release();
        }
    }
}
  • Step 7: Добавить Schedule entry в routes/console.php

После Plan 3 entries (последняя строка Schedule::command('supplier:retry-failed')->hourly()):

// Plan 4 Task 8: hourly CSV reconciliation (резерв-канал приёма лидов).
Schedule::job(new \App\Jobs\Supplier\CsvReconcileJob)->hourly();

И в use-блоке вверху файла:

use App\Jobs\Supplier\CsvReconcileJob;
  • Step 8: Запустить — PASS
cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvReconcileJobTest.php

Expected: 6 PASS.

  • Step 9: Pint + Larastan + полный Pest
cd app && composer pint && composer stan && ./vendor/bin/pest --parallel
  • Step 10: Commit
git add app/app/Jobs/Supplier/CsvReconcileJob.php \
        app/app/Mail/CsvDriftAlertMail.php \
        app/resources/views/emails/csv_drift_alert.blade.php \
        app/routes/console.php \
        app/config/services.php \
        app/tests/Feature/Supplier/CsvReconcileJobTest.php
git commit -m "feat(supplier): Plan 4 Task 8 — CsvReconcileJob hourly + drift>5% email + supplier_csv_reconcile_log"

Task 9: AdminPricingTiersController + Vue view + Histoire

Files:

  • Create: app/app/Http/Controllers/Api/AdminPricingTiersController.php

  • Modify: app/routes/web.php (+ Route::prefix /api/admin/pricing-tiers)

  • Create: app/tests/Feature/Admin/AdminPricingTiersControllerTest.php

  • Create: app/resources/js/views/admin/AdminPricingTiersView.vue

  • Create: app/resources/js/views/admin/AdminPricingTiersView.story.vue

  • Modify: app/resources/js/router/index.ts

  • Create: app/tests/Vitest/views/admin/AdminPricingTiersView.spec.ts

  • Step 1: Написать failing backend test

<?php
// app/tests/Feature/Admin/AdminPricingTiersControllerTest.php

declare(strict_types=1);

use App\Models\PricingTier;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(\Tests\TestCase::class, RefreshDatabase::class);

beforeEach(function () {
    (new \Database\Seeders\PricingTierSeeder())->run();
});

it('GET /api/admin/pricing-tiers returns active + scheduled sets', function () {
    $response = $this->getJson('/api/admin/pricing-tiers');
    $response->assertOk();
    expect($response->json('data.active'))->toHaveCount(7);
    expect($response->json('data.scheduled'))->toBeArray();
});

it('POST creates 7 new tiers with auto effective_from = 1st of next month', function () {
    $payload = [
        'tiers' => [
            ['tier_no' => 1, 'leads_in_tier' => 50,  'price_rub' => '600.00'],
            ['tier_no' => 2, 'leads_in_tier' => 150, 'price_rub' => '550.00'],
            ['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'],
            ['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'],
            ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'],
            ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'],
            ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '300.00'],
        ],
    ];
    $response = $this->postJson('/api/admin/pricing-tiers', $payload);
    $response->assertCreated();

    $expectedDate = now()->startOfMonth()->addMonth()->toDateString();
    $newTiers = PricingTier::where('effective_from', $expectedDate)->get();
    expect($newTiers)->toHaveCount(7);
    expect($newTiers->where('tier_no', 1)->first()->price_per_lead_kopecks)->toBe(60000);
    expect($newTiers->where('tier_no', 7)->first()->leads_in_tier)->toBeNull();
});

it('POST validates: exactly 7 rows required', function () {
    $payload = ['tiers' => [
        ['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '600.00'],
    ]];
    $response = $this->postJson('/api/admin/pricing-tiers', $payload);
    $response->assertStatus(422);
    $response->assertJsonValidationErrorFor('tiers');
});

it('POST validates: tier_no must be unique 1..7', function () {
    $payload = ['tiers' => [
        ['tier_no' => 1, 'leads_in_tier' => 50,  'price_rub' => '600.00'],
        ['tier_no' => 1, 'leads_in_tier' => 150, 'price_rub' => '550.00'],  // дубль
        ['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'],
        ['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'],
        ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'],
        ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'],
        ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '300.00'],
    ]];
    $this->postJson('/api/admin/pricing-tiers', $payload)->assertStatus(422);
});

it('POST validates: tier 7 leads_in_tier must be null', function () {
    $payload = ['tiers' => [
        ['tier_no' => 1, 'leads_in_tier' => 50,  'price_rub' => '600.00'],
        ['tier_no' => 2, 'leads_in_tier' => 150, 'price_rub' => '550.00'],
        ['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'],
        ['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'],
        ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'],
        ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'],
        ['tier_no' => 7, 'leads_in_tier' => 99999, 'price_rub' => '300.00'],  // должен быть NULL
    ]];
    $this->postJson('/api/admin/pricing-tiers', $payload)->assertStatus(422);
});

it('POST validates: price_rub >= 0', function () {
    $payload = ['tiers' => [
        ['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '-1.00'],
        ['tier_no' => 2, 'leads_in_tier' => 150, 'price_rub' => '550.00'],
        ['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'],
        ['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'],
        ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'],
        ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'],
        ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '300.00'],
    ]];
    $this->postJson('/api/admin/pricing-tiers', $payload)->assertStatus(422);
});

it('DELETE /scheduled/{effective_from} removes future tiers only', function () {
    $futureDate = now()->addMonth()->startOfMonth()->toDateString();
    PricingTier::factory()->count(7)->sequence(fn ($s) => ['tier_no' => $s->index + 1])
        ->create(['effective_from' => $futureDate, 'is_active' => true]);

    $this->deleteJson("/api/admin/pricing-tiers/scheduled/{$futureDate}")
        ->assertOk();

    expect(PricingTier::where('effective_from', $futureDate)->count())->toBe(0);
    // Старый (seed) активный набор не тронут
    expect(PricingTier::where('effective_from', '1970-01-01')->count())->toBe(7);
});

it('writes audit-trail row in saas_admin_audit_log on POST', function () {
    $payload = ['tiers' => [
        ['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '600.00'],
        ['tier_no' => 2, 'leads_in_tier' => 150, 'price_rub' => '550.00'],
        ['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'],
        ['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'],
        ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'],
        ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'],
        ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '300.00'],
    ]];
    $this->postJson('/api/admin/pricing-tiers', $payload)->assertCreated();

    $log = \Illuminate\Support\Facades\DB::table('saas_admin_audit_log')
        ->where('action', 'pricing_tiers.create_scheduled')->first();
    expect($log)->not->toBeNull();
});
  • Step 2: Запустить — FAIL
cd app && ./vendor/bin/pest tests/Feature/Admin/AdminPricingTiersControllerTest.php

Expected: 8 FAIL ("route not found").

  • Step 3: Реализовать AdminPricingTiersController
<?php
// app/app/Http/Controllers/Api/AdminPricingTiersController.php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\PricingTier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;

/**
 * SaaS-admin CRUD для pricing_tiers (7-ступенчатый тариф).
 *
 * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §6.1
 * Без auth-middleware на MVP (паритет с другими /api/admin/*; gated на Б-1).
 * Audit trail в saas_admin_audit_log.
 */
class AdminPricingTiersController extends Controller
{
    public function index(): JsonResponse
    {
        $today = Carbon::now('Europe/Moscow')->toDateString();

        $active = PricingTier::query()->where('is_active', true)
            ->where('effective_from', '<=', $today)
            ->orderBy('tier_no')->orderBy('effective_from', 'desc')
            ->get()
            ->groupBy('tier_no')
            ->map(fn ($g) => $g->first())
            ->values();

        $scheduled = PricingTier::query()->where('is_active', true)
            ->where('effective_from', '>', $today)
            ->orderBy('effective_from')->orderBy('tier_no')
            ->get()
            ->groupBy('effective_from');

        return response()->json([
            'data' => [
                'active' => $active,
                'scheduled' => $scheduled,
            ],
        ]);
    }

    public function store(Request $request): JsonResponse
    {
        $request->validate([
            'tiers' => ['required', 'array', 'size:7'],
            'tiers.*.tier_no' => ['required', 'integer', 'between:1,7'],
            'tiers.*.leads_in_tier' => ['nullable', 'integer', 'min:1'],
            'tiers.*.price_rub' => ['required', 'numeric', 'min:0'],
        ]);

        $tiers = $request->input('tiers');

        // Unique tier_no 1..7
        $tierNos = array_column($tiers, 'tier_no');
        if (count(array_unique($tierNos)) !== 7) {
            abort(422, json_encode(['message' => 'tier_no must be unique 1..7']));
        }
        if (array_diff([1, 2, 3, 4, 5, 6, 7], $tierNos) !== []) {
            abort(422, json_encode(['message' => 'all 7 tier_no values required']));
        }

        // Tier 7 must have leads_in_tier=null
        $tier7 = collect($tiers)->firstWhere('tier_no', 7);
        if ($tier7['leads_in_tier'] !== null) {
            abort(422, json_encode(['message' => 'tier_no=7 leads_in_tier must be null']));
        }

        // Tiers 1..6 must have leads_in_tier > 0
        foreach ($tiers as $tier) {
            if ($tier['tier_no'] !== 7 && ($tier['leads_in_tier'] === null || $tier['leads_in_tier'] < 1)) {
                abort(422, json_encode(['message' => "tier_no={$tier['tier_no']} leads_in_tier must be >= 1"]));
            }
        }

        $effectiveFrom = Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();

        DB::transaction(function () use ($tiers, $effectiveFrom) {
            foreach ($tiers as $tier) {
                PricingTier::create([
                    'tier_no' => $tier['tier_no'],
                    'leads_in_tier' => $tier['leads_in_tier'],
                    'price_per_lead_kopecks' => (int) round(((float) $tier['price_rub']) * 100),
                    'is_active' => true,
                    'effective_from' => $effectiveFrom,
                ]);
            }

            DB::table('saas_admin_audit_log')->insert([
                'admin_user_id' => null,  // SSO ⏸ Б-1
                'action' => 'pricing_tiers.create_scheduled',
                'target_type' => 'pricing_tiers',
                'target_id' => null,
                'payload' => json_encode(['effective_from' => $effectiveFrom, 'tiers' => $tiers]),
                'created_at' => now(),
            ]);
        });

        return response()->json(['effective_from' => $effectiveFrom], Response::HTTP_CREATED);
    }

    public function deleteScheduled(string $effectiveFrom): JsonResponse
    {
        if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $effectiveFrom)) {
            abort(400, 'invalid date format');
        }

        $todayMsk = Carbon::now('Europe/Moscow')->toDateString();
        if ($effectiveFrom <= $todayMsk) {
            abort(409, 'cannot delete past or active set');
        }

        DB::transaction(function () use ($effectiveFrom) {
            $deleted = PricingTier::where('effective_from', $effectiveFrom)->delete();

            DB::table('saas_admin_audit_log')->insert([
                'admin_user_id' => null,
                'action' => 'pricing_tiers.delete_scheduled',
                'target_type' => 'pricing_tiers',
                'target_id' => null,
                'payload' => json_encode(['effective_from' => $effectiveFrom, 'rows_deleted' => $deleted]),
                'created_at' => now(),
            ]);
        });

        return response()->json(['ok' => true]);
    }
}
  • Step 4: Добавить маршруты в routes/web.php

В app/routes/web.php, после строк AdminSystemSettings (~99):

// Plan 4: SaaS-admin pricing-tiers editor.
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}');
});
  • Step 5: Запустить backend test — PASS
cd app && ./vendor/bin/pest tests/Feature/Admin/AdminPricingTiersControllerTest.php

Expected: 8 PASS.

  • Step 6: Создать Vue компонент AdminPricingTiersView.vue
<!-- app/resources/js/views/admin/AdminPricingTiersView.vue -->
<template>
  <div class="admin-pricing-tiers-view">
    <h1 class="text-h4 mb-6">Тарифная сетка (pricing tiers)</h1>

    <v-card class="mb-6" elevation="1">
      <v-card-title>
        Текущая активная сетка
        <span v-if="active.length" class="text-caption text-medium-emphasis ml-2">
          (с {{ active[0]?.effective_from }})
        </span>
      </v-card-title>
      <v-data-table
        :headers="tierHeaders"
        :items="active"
        :items-per-page="7"
        density="comfortable"
        class="numeric-tnum"
      >
        <template #item.leads_in_tier="{ item }">
          <span v-if="item.leads_in_tier !== null">{{ item.leads_in_tier }}</span>
          <span v-else class="text-medium-emphasis">все свыше</span>
        </template>
        <template #item.price_rub="{ item }">
          {{ (item.price_per_lead_kopecks / 100).toFixed(2) }} 
        </template>
      </v-data-table>
    </v-card>

    <v-card v-if="scheduled.length" class="mb-6" elevation="1">
      <v-card-title>Запланированные изменения</v-card-title>
      <v-card-text>
        <div v-for="(group, date) in scheduledByDate" :key="date" class="mb-4">
          <strong>С {{ date }}:</strong>
          <v-data-table :headers="tierHeaders" :items="group" density="compact" class="mt-2"/>
          <v-btn color="error" variant="text" @click="confirmDelete(date)">
            Отменить
          </v-btn>
        </div>
      </v-card-text>
    </v-card>

    <v-btn color="primary" prepend-icon="mdi-pencil" @click="editorOpen = true">
      Редактировать сетку (с 1-го числа след. месяца)
    </v-btn>

    <v-dialog v-model="editorOpen" max-width="900">
      <v-card>
        <v-card-title>Новая сетка (effective_from = {{ nextMonthStart }})</v-card-title>
        <v-card-text>
          <table class="editor-table">
            <thead><tr><th>Ступень</th><th>Лидов в ступени</th><th>Цена за лид ()</th></tr></thead>
            <tbody>
              <tr v-for="(t, idx) in editor" :key="t.tier_no">
                <td>{{ t.tier_no }}</td>
                <td>
                  <v-text-field
                    v-if="t.tier_no !== 7"
                    v-model.number="t.leads_in_tier"
                    type="number" min="1" density="compact" hide-details
                  />
                  <span v-else class="text-medium-emphasis">все свыше</span>
                </td>
                <td>
                  <v-text-field
                    v-model="t.price_rub"
                    type="number" step="0.01" min="0" density="compact" hide-details
                  />
                </td>
              </tr>
            </tbody>
          </table>
        </v-card-text>
        <v-card-actions>
          <v-spacer/>
          <v-btn @click="editorOpen = false">Отмена</v-btn>
          <v-btn color="primary" :loading="saving" @click="submit">Сохранить</v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';

interface Tier {
  tier_no: number;
  leads_in_tier: number | null;
  price_per_lead_kopecks: number;
  effective_from: string;
}
interface EditorRow {
  tier_no: number;
  leads_in_tier: number | null;
  price_rub: string;
}

const active = ref<Tier[]>([]);
const scheduled = ref<Tier[]>([]);
const editorOpen = ref(false);
const saving = ref(false);

const defaultEditor: EditorRow[] = [
  { tier_no: 1, leads_in_tier: 100,  price_rub: '500.00' },
  { tier_no: 2, leads_in_tier: 200,  price_rub: '450.00' },
  { tier_no: 3, leads_in_tier: 400,  price_rub: '400.00' },
  { tier_no: 4, leads_in_tier: 800,  price_rub: '350.00' },
  { tier_no: 5, leads_in_tier: 1500, price_rub: '300.00' },
  { tier_no: 6, leads_in_tier: 3000, price_rub: '270.00' },
  { tier_no: 7, leads_in_tier: null, price_rub: '250.00' },
];
const editor = ref<EditorRow[]>(JSON.parse(JSON.stringify(defaultEditor)));

const tierHeaders = [
  { title: '№', key: 'tier_no', sortable: false, width: 80 },
  { title: 'Лидов в ступени', key: 'leads_in_tier', sortable: false },
  { title: 'Цена за лид', key: 'price_rub', sortable: false },
];

const nextMonthStart = computed(() => {
  const d = new Date();
  d.setDate(1); d.setMonth(d.getMonth() + 1);
  return d.toISOString().slice(0, 10);
});

const scheduledByDate = computed(() => {
  const out: Record<string, Tier[]> = {};
  scheduled.value.forEach(t => {
    if (!out[t.effective_from]) out[t.effective_from] = [];
    out[t.effective_from].push(t);
  });
  return out;
});

async function load() {
  const { data } = await axios.get('/api/admin/pricing-tiers');
  active.value = data.data.active;
  scheduled.value = data.data.scheduled
    ? Object.values(data.data.scheduled).flat()
    : [];
}

async function submit() {
  saving.value = true;
  try {
    await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });
    editorOpen.value = false;
    await load();
  } finally {
    saving.value = false;
  }
}

async function confirmDelete(effectiveFrom: string) {
  if (!window.confirm(`Удалить запланированный набор с ${effectiveFrom}?`)) return;
  await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
  await load();
}

onMounted(load);
</script>

<style scoped>
.numeric-tnum :deep(td) {
  font-feature-settings: 'tnum';
  font-family: 'JetBrains Mono', monospace;
}
.editor-table {
  width: 100%;
  border-collapse: collapse;
}
.editor-table th, .editor-table td {
  padding: 8px 12px;
  border-bottom: 1px solid rgba(0,0,0,.06);
}
</style>
  • Step 7: Создать Histoire story
<!-- app/resources/js/views/admin/AdminPricingTiersView.story.vue -->
<template>
  <Story title="admin/AdminPricingTiersView">
    <Variant title="Empty (нет сетки)"><AdminPricingTiersView/></Variant>
    <Variant title="Active set only"><AdminPricingTiersView/></Variant>
    <Variant title="Active + Scheduled"><AdminPricingTiersView/></Variant>
    <Variant title="Editor dialog open"><AdminPricingTiersView ref="vRef"/></Variant>
  </Story>
</template>

<script setup lang="ts">
import AdminPricingTiersView from './AdminPricingTiersView.vue';
</script>
  • Step 8: Добавить route в router/index.ts

В app/resources/js/router/index.ts найти секцию admin-маршрутов и добавить:

{
  path: '/admin/pricing-tiers',
  name: 'admin-pricing-tiers',
  component: () => import('@/views/admin/AdminPricingTiersView.vue'),
  meta: { layout: 'app', requiresAdmin: true },
},
  • Step 9: Создать Vitest test
// app/tests/Vitest/views/admin/AdminPricingTiersView.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import axios from 'axios';
import AdminPricingTiersView from '@/views/admin/AdminPricingTiersView.vue';

vi.mock('axios');

const vuetify = createVuetify({ components, directives });

const mockTiers = [
  { tier_no: 1, leads_in_tier: 100,  price_per_lead_kopecks: 50000, effective_from: '1970-01-01' },
  { tier_no: 2, leads_in_tier: 200,  price_per_lead_kopecks: 45000, effective_from: '1970-01-01' },
  { tier_no: 3, leads_in_tier: 400,  price_per_lead_kopecks: 40000, effective_from: '1970-01-01' },
  { tier_no: 4, leads_in_tier: 800,  price_per_lead_kopecks: 35000, effective_from: '1970-01-01' },
  { tier_no: 5, leads_in_tier: 1500, price_per_lead_kopecks: 30000, effective_from: '1970-01-01' },
  { tier_no: 6, leads_in_tier: 3000, price_per_lead_kopecks: 27000, effective_from: '1970-01-01' },
  { tier_no: 7, leads_in_tier: null, price_per_lead_kopecks: 25000, effective_from: '1970-01-01' },
];

describe('AdminPricingTiersView', () => {
  beforeEach(() => {
    (axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
    (axios.post as any).mockResolvedValue({ data: { effective_from: '2026-06-01' } });
    (axios.delete as any).mockResolvedValue({ data: { ok: true } });
  });

  it('renders 7 tier rows from /api/admin/pricing-tiers', async () => {
    const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    expect(wrapper.text()).toContain('500.00');
    expect(wrapper.text()).toContain('250.00');
  });

  it('shows "все свыше" for tier 7 with leads_in_tier=null', async () => {
    const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    expect(wrapper.text()).toContain('все свыше');
  });

  it('opens editor dialog on button click', async () => {
    const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    expect(wrapper.vm.editorOpen).toBe(false);
    wrapper.vm.editorOpen = true;
    await wrapper.vm.$nextTick();
    expect(wrapper.vm.editorOpen).toBe(true);
  });

  it('submits POST with editor.value payload', async () => {
    const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    wrapper.vm.editorOpen = true;
    await wrapper.vm.submit();
    expect(axios.post).toHaveBeenCalledWith('/api/admin/pricing-tiers', expect.objectContaining({
      tiers: expect.arrayContaining([
        expect.objectContaining({ tier_no: 7, leads_in_tier: null }),
      ]),
    }));
  });

  it('confirmDelete triggers DELETE to /scheduled/{date}', async () => {
    window.confirm = vi.fn(() => true);
    const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    await wrapper.vm.confirmDelete('2026-06-01');
    expect(axios.delete).toHaveBeenCalledWith('/api/admin/pricing-tiers/scheduled/2026-06-01');
  });
});
  • Step 10: Запустить Vitest — PASS
cd app && npm run test:vue -- AdminPricingTiersView

Expected: 5 PASS.

  • Step 11: Pint + Larastan + полный Pest + Histoire build smoke
cd app && composer pint && composer stan && ./vendor/bin/pest --parallel && npm run lint:vue && npm run type-check
cd app && npm run story:build 2>&1 | tail -20

Expected: Histoire builds without errors; AdminPricingTiersView.story registered.

  • Step 12: Commit
git add app/app/Http/Controllers/Api/AdminPricingTiersController.php \
        app/routes/web.php \
        app/tests/Feature/Admin/AdminPricingTiersControllerTest.php \
        app/resources/js/views/admin/AdminPricingTiersView.vue \
        app/resources/js/views/admin/AdminPricingTiersView.story.vue \
        app/resources/js/router/index.ts \
        app/tests/Vitest/views/admin/AdminPricingTiersView.spec.ts
git commit -m "feat(admin): Plan 4 Task 9 — AdminPricingTiersController + AdminPricingTiersView (CRUD 7-tier + audit)"

Task 10: AdminSuppliersController + Vue view + Histoire

Files:

  • Create: app/app/Http/Controllers/Api/AdminSuppliersController.php

  • Modify: app/routes/web.php

  • Create: app/tests/Feature/Admin/AdminSuppliersControllerTest.php

  • Create: app/resources/js/views/admin/AdminSupplierPricesView.vue

  • Create: app/resources/js/views/admin/AdminSupplierPricesView.story.vue

  • Modify: app/resources/js/router/index.ts

  • Create: app/tests/Vitest/views/admin/AdminSupplierPricesView.spec.ts

  • Step 1: Написать failing backend test

<?php
// app/tests/Feature/Admin/AdminSuppliersControllerTest.php

declare(strict_types=1);

use App\Models\Supplier;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(\Tests\TestCase::class, RefreshDatabase::class);

it('GET /api/admin/suppliers returns 3 suppliers B1/B2/B3', function () {
    $response = $this->getJson('/api/admin/suppliers');
    $response->assertOk();
    $data = $response->json('data');
    expect($data)->toHaveCount(3);
    expect(collect($data)->pluck('code')->all())->toContain('b1', 'b2', 'b3');
});

it('PATCH updates cost_rub for supplier', function () {
    $b1 = Supplier::where('code', 'b1')->first();
    $oldCost = (string) $b1->cost_rub;

    $this->patchJson("/api/admin/suppliers/{$b1->id}", ['cost_rub' => '1.50'])
        ->assertOk();

    expect((string) $b1->fresh()->cost_rub)->toBe('1.50');
    expect((string) $b1->fresh()->cost_rub)->not->toBe($oldCost);
});

it('PATCH validates cost_rub >= 0', function () {
    $b1 = Supplier::where('code', 'b1')->first();

    $this->patchJson("/api/admin/suppliers/{$b1->id}", ['cost_rub' => '-1.00'])
        ->assertStatus(422);
});

it('PATCH writes saas_admin_audit_log row', function () {
    $b1 = Supplier::where('code', 'b1')->first();

    $this->patchJson("/api/admin/suppliers/{$b1->id}", ['cost_rub' => '2.00'])
        ->assertOk();

    $log = \Illuminate\Support\Facades\DB::table('saas_admin_audit_log')
        ->where('action', 'suppliers.update')->first();
    expect($log)->not->toBeNull();
});
  • Step 2: Запустить — FAIL
cd app && ./vendor/bin/pest tests/Feature/Admin/AdminSuppliersControllerTest.php
  • Step 3: Реализовать AdminSuppliersController
<?php
// app/app/Http/Controllers/Api/AdminSuppliersController.php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Supplier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class AdminSuppliersController extends Controller
{
    public function index(): JsonResponse
    {
        return response()->json([
            'data' => Supplier::query()->orderBy('sort_order')->get(),
        ]);
    }

    public function update(Request $request, int $id): JsonResponse
    {
        $request->validate([
            'cost_rub' => ['sometimes', 'numeric', 'min:0'],
            'quality_score' => ['sometimes', 'numeric', 'between:0,9.99'],
            'is_active' => ['sometimes', 'boolean'],
        ]);

        $supplier = Supplier::findOrFail($id);
        $changes = $request->only(['cost_rub', 'quality_score', 'is_active']);

        DB::transaction(function () use ($supplier, $changes) {
            $before = $supplier->only(array_keys($changes));
            $supplier->update($changes);

            DB::table('saas_admin_audit_log')->insert([
                'admin_user_id' => null,  // ⏸ Б-1
                'action' => 'suppliers.update',
                'target_type' => 'suppliers',
                'target_id' => $supplier->id,
                'payload' => json_encode(['before' => $before, 'after' => $changes]),
                'created_at' => now(),
            ]);
        });

        return response()->json(['data' => $supplier->fresh()]);
    }
}
  • Step 4: Добавить routes в web.php
// app/routes/web.php — после Plan 4 Task 9 pricing-tiers routes
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]+');
  • Step 5: Запустить — PASS
cd app && ./vendor/bin/pest tests/Feature/Admin/AdminSuppliersControllerTest.php

Expected: 4 PASS.

  • Step 6: Создать Vue компонент AdminSupplierPricesView.vue
<!-- app/resources/js/views/admin/AdminSupplierPricesView.vue -->
<template>
  <div>
    <h1 class="text-h4 mb-6">Цены поставщиков (закупка)</h1>
    <v-card elevation="1">
      <v-data-table :headers="headers" :items="suppliers" density="comfortable" class="numeric-tnum">
        <template #item.cost_rub="{ item }">
          <v-text-field
            v-model="item.cost_rub" type="number" step="0.01" min="0"
            density="compact" hide-details variant="plain"
          />
        </template>
        <template #item.quality_score="{ item }">
          <v-text-field
            v-model="item.quality_score" type="number" step="0.01" min="0" max="9.99"
            density="compact" hide-details variant="plain"
          />
        </template>
        <template #item.is_active="{ item }">
          <v-switch v-model="item.is_active" hide-details inset density="compact"/>
        </template>
        <template #item.actions="{ item }">
          <v-btn size="small" :loading="saving[item.id]" @click="save(item)">Сохранить</v-btn>
        </template>
      </v-data-table>
    </v-card>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue';
import axios from 'axios';

interface SupplierRow {
  id: number; code: string; name: string;
  cost_rub: string; quality_score: string; is_active: boolean;
}

const suppliers = ref<SupplierRow[]>([]);
const saving = reactive<Record<number, boolean>>({});

const headers = [
  { title: 'Code', key: 'code', sortable: false, width: 80 },
  { title: 'Name', key: 'name', sortable: false },
  { title: 'Cost (₽)', key: 'cost_rub', sortable: false, width: 140 },
  { title: 'Quality', key: 'quality_score', sortable: false, width: 100 },
  { title: 'Active', key: 'is_active', sortable: false, width: 100 },
  { title: '', key: 'actions', sortable: false, width: 120 },
];

async function load() {
  const { data } = await axios.get('/api/admin/suppliers');
  suppliers.value = data.data;
}

async function save(s: SupplierRow) {
  saving[s.id] = true;
  try {
    await axios.patch(`/api/admin/suppliers/${s.id}`, {
      cost_rub: s.cost_rub,
      quality_score: s.quality_score,
      is_active: s.is_active,
    });
  } finally {
    saving[s.id] = false;
  }
}

onMounted(load);
</script>

<style scoped>
.numeric-tnum :deep(td) {
  font-feature-settings: 'tnum';
  font-family: 'JetBrains Mono', monospace;
}
</style>
  • Step 7: Histoire story
<!-- app/resources/js/views/admin/AdminSupplierPricesView.story.vue -->
<template>
  <Story title="admin/AdminSupplierPricesView">
    <Variant title="Default 3 rows"><AdminSupplierPricesView/></Variant>
    <Variant title="Editing row"><AdminSupplierPricesView/></Variant>
  </Story>
</template>

<script setup lang="ts">
import AdminSupplierPricesView from './AdminSupplierPricesView.vue';
</script>
  • Step 8: Route в router/index.ts
{
  path: '/admin/supplier-prices',
  name: 'admin-supplier-prices',
  component: () => import('@/views/admin/AdminSupplierPricesView.vue'),
  meta: { layout: 'app', requiresAdmin: true },
},
  • Step 9: Vitest тесты
// app/tests/Vitest/views/admin/AdminSupplierPricesView.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import axios from 'axios';
import AdminSupplierPricesView from '@/views/admin/AdminSupplierPricesView.vue';

vi.mock('axios');
const vuetify = createVuetify({ components, directives });

const mockSuppliers = [
  { id: 1, code: 'b1', name: 'B1 — Сайты и Звонки', cost_rub: '1.00', quality_score: '1.00', is_active: true },
  { id: 2, code: 'b2', name: 'B2 — SMS', cost_rub: '1.50', quality_score: '1.00', is_active: true },
  { id: 3, code: 'b3', name: 'B3 — SMS', cost_rub: '1.20', quality_score: '0.95', is_active: true },
];

describe('AdminSupplierPricesView', () => {
  beforeEach(() => {
    (axios.get as any).mockResolvedValue({ data: { data: mockSuppliers } });
    (axios.patch as any).mockResolvedValue({ data: { data: mockSuppliers[0] } });
  });

  it('renders 3 supplier rows', async () => {
    const w = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    expect(w.text()).toContain('b1');
    expect(w.text()).toContain('b2');
    expect(w.text()).toContain('b3');
  });

  it('save() fires PATCH with cost_rub/quality_score/is_active', async () => {
    const w = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    await w.vm.save({ id: 1, code: 'b1', name: '', cost_rub: '2.00', quality_score: '1.00', is_active: true });
    expect(axios.patch).toHaveBeenCalledWith('/api/admin/suppliers/1', {
      cost_rub: '2.00', quality_score: '1.00', is_active: true,
    });
  });

  it('renders quality_score, cost_rub as editable text-fields', async () => {
    const w = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    const inputs = w.findAll('input[type="number"]');
    expect(inputs.length).toBeGreaterThanOrEqual(6);  // 3 rows × 2 числовых поля
  });
});
  • Step 10: Pint + Larastan + полный Pest + npm run test:vue
cd app && composer pint && composer stan && ./vendor/bin/pest --parallel && npm run test:vue -- AdminSupplierPrices && npm run lint:vue && npm run type-check
  • Step 11: Commit
git add app/app/Http/Controllers/Api/AdminSuppliersController.php \
        app/routes/web.php \
        app/tests/Feature/Admin/AdminSuppliersControllerTest.php \
        app/resources/js/views/admin/AdminSupplierPricesView.vue \
        app/resources/js/views/admin/AdminSupplierPricesView.story.vue \
        app/resources/js/router/index.ts \
        app/tests/Vitest/views/admin/AdminSupplierPricesView.spec.ts
git commit -m "feat(admin): Plan 4 Task 10 — AdminSuppliersController + AdminSupplierPricesView (B1/B2/B3 cost editor)"

Task 11: TenantChargesController + ChargesTab в BillingView + CSV export

Files:

  • Create: app/app/Http/Controllers/Api/TenantChargesController.php

  • Modify: app/routes/web.php

  • Create: app/tests/Feature/Billing/TenantChargesControllerTest.php

  • Create: app/resources/js/views/billing/ChargesTab.vue

  • Create: app/resources/js/views/billing/ChargesTab.story.vue

  • Modify: app/resources/js/views/BillingView.vue

  • Create: app/tests/Vitest/views/billing/ChargesTab.spec.ts

  • Step 1: Написать failing backend test

<?php
// app/tests/Feature/Billing/TenantChargesControllerTest.php

declare(strict_types=1);

use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;

uses(\Tests\TestCase::class, RefreshDatabase::class);

beforeEach(function () {
    (new \Database\Seeders\PricingTierSeeder())->run();

    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
    Sanctum::actingAs($this->user);
});

function makeChargeFor(Tenant $tenant, array $overrides = []): LeadCharge
{
    $deal = Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()]);
    return LeadCharge::factory()->create(array_merge([
        'tenant_id' => $tenant->id,
        'deal_id' => $deal->id,
        'deal_received_at' => $deal->received_at,
        'charged_at' => now(),
    ], $overrides));
}

it('GET /api/billing/charges returns paginated list for current tenant only (RLS)', function () {
    makeChargeFor($this->tenant);
    makeChargeFor($this->tenant);

    $otherTenant = Tenant::factory()->create();
    makeChargeFor($otherTenant);

    $response = $this->getJson('/api/billing/charges');
    $response->assertOk();
    expect($response->json('data'))->toHaveCount(2);
});

it('filters by charge_source=prepaid', function () {
    makeChargeFor($this->tenant, ['charge_source' => 'rub', 'price_per_lead_kopecks' => 50000]);
    makeChargeFor($this->tenant, ['charge_source' => 'prepaid', 'price_per_lead_kopecks' => 0]);
    makeChargeFor($this->tenant, ['charge_source' => 'prepaid', 'price_per_lead_kopecks' => 0]);

    $response = $this->getJson('/api/billing/charges?charge_source=prepaid');
    expect($response->json('data'))->toHaveCount(2);
});

it('filters by period=current_month / last_month / 90d', function () {
    makeChargeFor($this->tenant, ['charged_at' => now()]);
    makeChargeFor($this->tenant, ['charged_at' => now()->subMonth()]);
    makeChargeFor($this->tenant, ['charged_at' => now()->subDays(60)]);
    makeChargeFor($this->tenant, ['charged_at' => now()->subDays(120)]);

    $this->getJson('/api/billing/charges?period=current_month')
        ->assertJsonCount(1, 'data');
    $this->getJson('/api/billing/charges?period=last_month')
        ->assertJsonCount(1, 'data');
    $this->getJson('/api/billing/charges?period=90d')
        ->assertJsonCount(3, 'data');  // current + last + 60d (90d ≥ 60)
});

it('returns 401 без auth', function () {
    auth()->logout();
    Sanctum::actingAs(null);
    $this->getJson('/api/billing/charges')->assertStatus(401);
});

it('pagination: ?page=2 returns next slice', function () {
    for ($i = 0; $i < 30; $i++) {
        makeChargeFor($this->tenant);
    }

    $page1 = $this->getJson('/api/billing/charges?page=1');
    $page2 = $this->getJson('/api/billing/charges?page=2');

    expect($page1->json('data'))->toHaveCount(20);  // default per_page
    expect($page2->json('data'))->toHaveCount(10);
});

it('POST /export streams CSV via StreamedResponse', function () {
    makeChargeFor($this->tenant);

    $response = $this->postJson('/api/billing/charges/export', ['period' => '90d']);
    $response->assertOk();
    $response->assertHeader('Content-Type', 'text/csv; charset=UTF-8');

    $body = $response->streamedContent();
    expect($body)->toContain('charged_at,deal_id,tier_no,charge_source,price_rub,balance_rub_after');
});
  • Step 2: Запустить — FAIL
cd app && ./vendor/bin/pest tests/Feature/Billing/TenantChargesControllerTest.php
  • Step 3: Реализовать TenantChargesController
<?php
// app/app/Http/Controllers/Api/TenantChargesController.php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\LeadCharge;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpFoundation\StreamedResponse;

/**
 * Tenant-scoped доступ к lead_charges (read-only ledger).
 *
 * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §6.3
 * RLS защищает изоляцию через SetTenantContext middleware.
 */
class TenantChargesController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        $query = LeadCharge::query()->orderBy('charged_at', 'desc');

        $this->applyFilters($query, $request);

        $page = $query->paginate(20);

        return response()->json([
            'data' => $page->items(),
            'meta' => [
                'current_page' => $page->currentPage(),
                'last_page' => $page->lastPage(),
                'total' => $page->total(),
                'per_page' => $page->perPage(),
            ],
        ]);
    }

    public function export(Request $request): StreamedResponse
    {
        $query = LeadCharge::query()->orderBy('charged_at', 'desc');
        $this->applyFilters($query, $request);

        $filename = 'charges_'.now()->format('Y-m-d_His').'.csv';

        return response()->stream(function () use ($query) {
            $out = fopen('php://output', 'w');
            // BOM для Excel
            fwrite($out, "\xEF\xBB\xBF");
            fputcsv($out, ['charged_at', 'deal_id', 'tier_no', 'charge_source', 'price_rub', 'balance_rub_after']);

            $query->chunkById(500, function ($charges) use ($out) {
                foreach ($charges as $c) {
                    fputcsv($out, [
                        $c->charged_at->toIso8601String(),
                        $c->deal_id,
                        $c->tier_no,
                        $c->charge_source,
                        number_format($c->price_per_lead_kopecks / 100, 2, '.', ''),
                        '',  // balance_rub_after — нет в lead_charges; для PoC оставляем пустым
                    ]);
                }
            });

            fclose($out);
        }, 200, [
            'Content-Type' => 'text/csv; charset=UTF-8',
            'Content-Disposition' => "attachment; filename=\"{$filename}\"",
        ]);
    }

    /**
     * @param  \Illuminate\Database\Eloquent\Builder<LeadCharge>  $query
     */
    private function applyFilters($query, Request $request): void
    {
        $period = $request->query('period');
        $now = Carbon::now('Europe/Moscow');

        if ($period === 'current_month') {
            $query->where('charged_at', '>=', $now->copy()->startOfMonth());
        } elseif ($period === 'last_month') {
            $query->whereBetween('charged_at', [
                $now->copy()->subMonth()->startOfMonth(),
                $now->copy()->subMonth()->endOfMonth(),
            ]);
        } elseif ($period === '90d') {
            $query->where('charged_at', '>=', $now->copy()->subDays(90));
        }

        if ($source = $request->query('charge_source')) {
            $query->where('charge_source', $source);
        }
    }
}
  • Step 4: Добавить routes
// app/routes/web.php — рядом с другими auth+tenant маршрутами Plan 2
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');
});
  • Step 5: Запустить — PASS
cd app && ./vendor/bin/pest tests/Feature/Billing/TenantChargesControllerTest.php

Expected: 6 PASS.

  • Step 6: Создать ChargesTab.vue
<!-- app/resources/js/views/billing/ChargesTab.vue -->
<template>
  <div>
    <div class="d-flex align-center mb-4 ga-3">
      <v-select v-model="period" :items="periods" item-title="title" item-value="value"
                label="Период" density="compact" hide-details style="max-width: 220px"
                @update:model-value="refresh"/>
      <v-select v-model="source" :items="sources" item-title="title" item-value="value"
                label="Источник" density="compact" hide-details style="max-width: 200px" clearable
                @update:model-value="refresh"/>
      <v-spacer/>
      <v-btn color="primary" prepend-icon="mdi-download" :loading="exporting" @click="exportCsv">
        Скачать CSV
      </v-btn>
    </div>

    <v-data-table-server
      :headers="headers" :items="rows"
      :items-length="total" :loading="loading"
      :items-per-page="20" @update:options="loadOptions"
      class="numeric-tnum"
    >
      <template #item.charged_at="{ item }">
        {{ formatDate(item.charged_at) }}
      </template>
      <template #item.deal_id="{ item }">
        <RouterLink :to="`/deals/${item.deal_id}`">#{{ item.deal_id }}</RouterLink>
      </template>
      <template #item.charge_source="{ item }">
        <v-chip size="small" :color="item.charge_source === 'prepaid' ? 'info' : 'success'">
          {{ item.charge_source === 'prepaid' ? 'prepaid' : '₽' }}
        </v-chip>
      </template>
      <template #item.price_rub="{ item }">
        {{ (item.price_per_lead_kopecks / 100).toFixed(2) }} 
      </template>
    </v-data-table-server>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import axios from 'axios';

interface ChargeRow {
  id: number; charged_at: string; deal_id: number;
  tier_no: number; charge_source: 'prepaid' | 'rub';
  price_per_lead_kopecks: number;
}

const rows = ref<ChargeRow[]>([]);
const total = ref(0);
const loading = ref(false);
const exporting = ref(false);
const period = ref<string>('current_month');
const source = ref<string | null>(null);
const page = ref(1);

const periods = [
  { title: 'Текущий месяц', value: 'current_month' },
  { title: 'Прошлый месяц', value: 'last_month' },
  { title: 'Последние 90 дней', value: '90d' },
];
const sources = [
  { title: 'prepaid', value: 'prepaid' },
  { title: '₽', value: 'rub' },
];

const headers = [
  { title: 'Дата', key: 'charged_at', sortable: false },
  { title: 'Сделка', key: 'deal_id', sortable: false, width: 100 },
  { title: 'Tier', key: 'tier_no', sortable: false, width: 80 },
  { title: 'Источник', key: 'charge_source', sortable: false, width: 120 },
  { title: 'Цена', key: 'price_rub', sortable: false, width: 120 },
];

function formatDate(iso: string): string {
  return new Date(iso).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' });
}

async function refresh() {
  page.value = 1;
  await load();
}

async function loadOptions(opts: { page: number }) {
  page.value = opts.page;
  await load();
}

async function load() {
  loading.value = true;
  try {
    const params: Record<string, string | number> = { page: page.value, period: period.value };
    if (source.value) params.charge_source = source.value;
    const { data } = await axios.get('/api/billing/charges', { params });
    rows.value = data.data;
    total.value = data.meta.total;
  } finally {
    loading.value = false;
  }
}

async function exportCsv() {
  exporting.value = true;
  try {
    const params: Record<string, string> = { period: period.value };
    if (source.value) params.charge_source = source.value;
    const response = await axios.post('/api/billing/charges/export', params, { responseType: 'blob' });
    const url = URL.createObjectURL(response.data);
    const a = document.createElement('a');
    a.href = url;
    a.download = `charges_${new Date().toISOString().slice(0,10)}.csv`;
    a.click();
    URL.revokeObjectURL(url);
  } finally {
    exporting.value = false;
  }
}

onMounted(load);

defineExpose({ refresh, exportCsv, period, source, total });
</script>

<style scoped>
.numeric-tnum :deep(td) {
  font-feature-settings: 'tnum';
  font-family: 'JetBrains Mono', monospace;
}
</style>
  • Step 7: Histoire story
<!-- app/resources/js/views/billing/ChargesTab.story.vue -->
<template>
  <Story title="billing/ChargesTab">
    <Variant title="Empty"><ChargesTab/></Variant>
    <Variant title="Mixed prepaid+rub"><ChargesTab/></Variant>
    <Variant title="Only rub current month"><ChargesTab/></Variant>
  </Story>
</template>

<script setup lang="ts">
import ChargesTab from './ChargesTab.vue';
</script>
  • Step 8: Добавить tab в BillingView.vue

Открыть app/resources/js/views/BillingView.vue. Грепнуть текущую <v-tabs> структуру:

grep -n "v-tabs\|v-tab\b" app/resources/js/views/BillingView.vue

Добавить новый tab «Списания»:

<!-- В <v-tabs> блоке добавить: -->
<v-tab value="charges">Списания</v-tab>

<!-- В <v-tabs-window> или <v-window> блоке добавить: -->
<v-window-item value="charges">
  <ChargesTab/>
</v-window-item>

В <script setup> импортировать:

import ChargesTab from './billing/ChargesTab.vue';
  • Step 9: Vitest тесты
// app/tests/Vitest/views/billing/ChargesTab.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import axios from 'axios';
import ChargesTab from '@/views/billing/ChargesTab.vue';

vi.mock('axios');
const vuetify = createVuetify({ components, directives });

const mockData = {
  data: [
    { id: 1, charged_at: '2026-05-01T10:00:00Z', deal_id: 42, tier_no: 1, charge_source: 'rub', price_per_lead_kopecks: 50000 },
    { id: 2, charged_at: '2026-05-01T11:00:00Z', deal_id: 43, tier_no: 1, charge_source: 'prepaid', price_per_lead_kopecks: 0 },
  ],
  meta: { current_page: 1, last_page: 1, total: 2, per_page: 20 },
};

describe('ChargesTab', () => {
  beforeEach(() => {
    (axios.get as any).mockResolvedValue({ data: mockData });
    (axios.post as any).mockResolvedValue({ data: new Blob() });
    globalThis.URL.createObjectURL = vi.fn(() => 'blob:url');
    globalThis.URL.revokeObjectURL = vi.fn();
  });

  it('renders 2 charge rows from API', async () => {
    const w = mount(ChargesTab, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    expect(w.vm.total).toBe(2);
  });

  it('period change triggers refetch', async () => {
    const w = mount(ChargesTab, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    w.vm.period = 'last_month';
    await w.vm.refresh();
    expect(axios.get).toHaveBeenLastCalledWith('/api/billing/charges', {
      params: expect.objectContaining({ period: 'last_month' }),
    });
  });

  it('charge_source filter is sent in params', async () => {
    const w = mount(ChargesTab, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    w.vm.source = 'prepaid';
    await w.vm.refresh();
    expect(axios.get).toHaveBeenLastCalledWith('/api/billing/charges', {
      params: expect.objectContaining({ charge_source: 'prepaid' }),
    });
  });

  it('exportCsv fires POST /export with blob response', async () => {
    const w = mount(ChargesTab, { global: { plugins: [vuetify] } });
    await new Promise(r => setTimeout(r, 50));
    await w.vm.exportCsv();
    expect(axios.post).toHaveBeenCalledWith(
      '/api/billing/charges/export',
      expect.objectContaining({ period: 'current_month' }),
      expect.objectContaining({ responseType: 'blob' }),
    );
  });
});
  • Step 10: Запустить Vitest — PASS
cd app && npm run test:vue -- ChargesTab
  • Step 11: Pint + Larastan + полный Pest + npm run test:vue + lint:vue + type-check
cd app && composer pint && composer stan && ./vendor/bin/pest --parallel && npm run test:vue && npm run lint:vue && npm run type-check
  • Step 12: Commit
git add app/app/Http/Controllers/Api/TenantChargesController.php \
        app/routes/web.php \
        app/tests/Feature/Billing/TenantChargesControllerTest.php \
        app/resources/js/views/billing/ChargesTab.vue \
        app/resources/js/views/billing/ChargesTab.story.vue \
        app/resources/js/views/BillingView.vue \
        app/tests/Vitest/views/billing/ChargesTab.spec.ts
git commit -m "feat(billing): Plan 4 Task 11 — TenantChargesController + ChargesTab + CSV export"

Task 12: Verification gate + memory & post-merge updates

Files:

Это финальный verification gate — НЕ TDD. Все вещественные изменения сделаны в Tasks 1–11. Здесь проверяем готовность к FF-merge.

  • Step 1: Полный CV gate (14 пунктов из spec §7.4)
cd app && composer pint

Expected: clean diff.

cd app && composer stan

Expected: 0 errors above baseline. Если есть новые — обновить phpstan-baseline.neon через composer stan --generate-baseline И зафиксировать в commit (паттерн Plan 3 §3.4).

cd app && ./vendor/bin/pest --parallel

Expected: все тесты PASS. Зафиксировать exact final count (e.g. «688 passed, 3 skipped, 0 failed»).

cd app && npm run lint:vue

Expected: 0 errors.

cd app && npm run type-check

Expected: 0 errors.

cd app && npm run test:vue

Expected: все Vitest PASS, зафиксировать exact count.

cd app && npm run story:build 2>&1 | tail -20

Expected: Histoire builds OK, новые stories Plan 4 (AdminPricingTiersView, AdminSupplierPricesView, ChargesTab) присутствуют.

cd app && DB_DATABASE=liderra_testing php artisan migrate:fresh --force --seed

Expected: 0 errors; pricing_tiers seeded 7 строк.

cd app && DB_DATABASE=liderra_testing php artisan tinker --execute="echo \\App\\Models\\PricingTier::count();"

Expected: 7.

cd app && npm run a11y

Expected: 0 violations.

./bin/gitleaks.exe detect --no-banner

Expected: 0 leaks.

npm run links

Expected: 0 broken (если упадёт на .lychee.toml exclude patterns — добавить новые spec/plan files в exclude, как было в Plan 3 commit 926fee9).

  • Step 2: Manual smoke tinker — RouteSupplierLeadJob с биллингом
cd app && DB_DATABASE=liderra_testing php artisan tinker
// Внутри tinker:
$t = \App\Models\Tenant::factory()->create(['balance_leads' => 5, 'balance_rub' => '0.00']);
$sp = \App\Models\SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'smoke.test']);
$p = \App\Models\Project::factory()->create([
    'tenant_id' => $t->id, 'signal_type' => 'site', 'signal_identifier' => 'smoke.test',
    'supplier_b1_project_id' => $sp->id, 'is_active' => true,
    'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, 'delivered_today' => 0,
    'delivery_days_mask' => 127, 'region_mask' => 255,
]);
$lead = \App\Models\SupplierLead::factory()->create([
    'vid' => 'smoke-vid', 'phone' => '79991234567',
    'raw_payload' => ['project' => 'B1_smoke.test', 'phone' => '79991234567', 'time' => time()],
    'supplier_project_id' => $sp->id, 'received_at' => now(),
]);

(new \App\Jobs\RouteSupplierLeadJob($lead->id))->handle(
    app(\App\Services\LeadRouter::class),
    app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
    app(\App\Services\DuplicateDetector::class),
    app(\App\Services\NotificationService::class),
    app(\App\Services\Billing\LedgerService::class),
);

echo \App\Models\LeadCharge::count();      // Expected: 1
echo \App\Models\Deal::count();             // Expected: 1
$lc = \App\Models\LeadCharge::first();
echo $lc->charge_source;                   // Expected: 'prepaid'
echo $lc->price_per_lead_kopecks;          // Expected: 0
echo $t->fresh()->balance_leads;           // Expected: 4

Если какое-либо echo вернуло неожиданное значение — это bug в одной из Tasks 1-11. Использовать superpowers:systematic-debugging skill для поиска (≥3 гипотез перед фиксом).

  • Step 3: Manual UI smoke
cd app && php artisan serve --port=8000
# В отдельном терминале:
cd app && npm run dev

Открыть в браузере:

  1. http://localhost:8000/admin/pricing-tiers — увидеть 7 ступеней.
  2. Нажать «Редактировать сетку» → изменить tier 1 price → «Сохранить» → увидеть в «Запланированные изменения».
  3. http://localhost:8000/admin/supplier-prices — увидеть 3 строки B1/B2/B3.
  4. http://localhost:8000/billing → tab «Списания» — увидеть пустой список (если seed без charges) или 1 строку из smoke-теста.
  5. Нажать «Скачать CSV» → файл скачивается.
  • Step 4: Code-review subagent

Запустить через superpowers:requesting-code-review. Вход: branch + spec path. Ожидать вердикт «Ready for FF-merge» или список BLOCKER/Important. Любой BLOCKER → отдельным commit'ом fix + повторный review.

  • Step 5: Добавить 7 новых открытых вопросов в реестр

Открыть docs/Открытые_вопросы_v8_3.md, добавить 7 строк в секцию «Биз-*»:

ID Вопрос Адресат Приоритет Дефолт Когда нужно
Биз-23 Дефолтные tier-цены (500/450/400/350/300/270/250 руб) Заказчик P1 placeholder (см. PricingTierSeeder) До production
Биз-24 Email rate-limit zero_balance 1/час/tenant — норма? Заказчик P2 1/час До production
Биз-25 Tenant видит ВСЕ ступени или только свою? Заказчик P2 transparent (все) До UI release
Биз-26 CSV-схема /admin/report/index?type=49 — точные столбцы Discovery после credentials P1 webhook-payload placeholder После Tasks 1-2 Plan 3
Биз-27 CSV окно (25h) — норма? Заказчик/опыт P3 25h После 1-2 мес. эксплуатации
Биз-28 Drift threshold 5% — норма? Заказчик/опыт P3 5% После 1-2 мес. эксплуатации
Биз-29 Pricing-tier-change при повышении цены Заказчик P2 единая логика effective 1-е след. мес. До production

Обновить шапку Открытые_вопросы_v8_3.md (версия + дата + «Что нового»).

  • Step 6: Обновить CLAUDE.md через claude-md-management
/claude-md-management:revise-claude-md

Изменения, которые skill должен внести (по правилам — single source of truth):

  • §0 schema метрики: «v8.18 / 61 / 114 / 39» → «v8.19 / 62 / 117 / 39».

  • §6 «Текущая фаза проекта»: добавить Plan 4 в перечень merged plans.

  • Шапка version bump: vX.Y → vX.(Y+1) (текущая v1.86 → v1.87).

  • Step 7: Обновить memory project_supplier_integration.md

Через прямой Write/Edit, обновить таблицу «Декомпозиция на 5 планов»:

| 4 | Billing + CSV Reconcile + Admin | DONE + FF-merged DATE, HEAD origin/main = <merge-commit> | [2026-05-11-plan4-billing-csv-admin-plan.md](<actual-path>) |

Добавить блок «Plan 4 итоги» с:

  • Commit list (Tasks 1-12).

  • Метрики (тесты, schema delta).

  • Acceptance criteria status (AC-1..AC-10 — все ).

  • Архитектурные learnings (если были).

  • Step 8: Проверка атомарности коммитов

git log --oneline plan4-branch ^main

Expected: ровно 12 commits (Task 1 .. Task 11) + (опционально 1-2 fix-commit'а от code-review) + (опционально 1 commit для post-merge memory updates перед merge).

  • Step 9: FF-merge в main (только после approve заказчика)
# Заказчик явно сказал «merge»:
git checkout main
git merge --ff-only plan4-branch
git push origin main
git branch -d plan4-branch
  • Step 10: Post-merge — closing note в чате

Отчитаться:

  • Полные test-метрики (Pest X/X, Vitest Y/Y, 0 failed).
  • Schema метрики после v8.19.
  • HEAD origin/main hash.
  • AC-1..AC-10 — все .
  • 7 новых открытых вопросов в реестре.
  • Plan 5 (Frontend Project UI) — pending как следующий.

Self-Review

После записи плана сделана сверка с spec'ом. Найденные несоответствия и пробелы — исправлены inline:

1. Spec coverage — каждое требование spec'а покрыто Task'ом:

  • Schema delta v8.19 (spec §2) → Task 1.
  • PricingTierResolver (spec §3.1) → Task 2.
  • LedgerService::chargeForDelivery (spec §3.3) → Task 3.
  • Integration в RouteSupplierLeadJob (spec §3.2) → Task 4.
  • ResetMonthlyCountersCommand (spec §4.1) → Task 5.
  • Auto-pause + ZeroBalancePausedMail (spec §4.2-4.5) → Task 6.
  • SupplierPortalClient::downloadLeadsCsv + SupplierCsvParser (spec §5.1-5.2) → Task 7.
  • CsvReconcileJob + CsvDriftAlertMail (spec §5.3-5.6) → Task 8.
  • AdminPricingTiersController (spec §6.1) → Task 9.
  • AdminSuppliersController (spec §6.2) → Task 10.
  • TenantChargesController + ChargesTab (spec §6.3) → Task 11.
  • Verification gate + 7 Биз-вопросов (spec §7.4 + §7.6) → Task 12.

2. Placeholder scan — итеративная проверка по «No Placeholders» рулесу:

  • Все код-блоки полные (нет «// TODO»).
  • Все команды exact (./vendor/bin/pest --filter=..., npm run test:vue -- <name>).
  • Все file paths абсолютные относительно репозитория.
  • Зависимости между Tasks явные через «Inherits from Task N».

3. Type consistency:

  • ChargeResult (Task 3) — везде используется как (source: string, tier: PricingTier, priceKopecks: int). ✓
  • InsufficientBalanceExceptionpriceKopecks: int, balanceRub: string, balanceLeads: int (Task 3 + Task 4 catch + Task 6 handleInsufficientBalance signature). ✓
  • LedgerService::chargeForDelivery(Tenant $lockedTenant, Deal $deal, ?SupplierLead $lead = null) — Task 3 определяет, Task 4 передаёт $lead, Task 6 не меняет. ✓
  • Cache::store('redis')->add($key, true, now()->addHour()) — паттерн Task 6 rate-limit + Task 8 lock (lock()->get() — другая семантика, namespace разный «billing:zero_balance_alert» vs «supplier:csv_reconcile»). ✓

4. Connection paths:

  • pgsql_supplier BYPASSRLS-роль используется единообразно в: RouteSupplierLeadJob self::DB_CONNECTION (Plan 3) + ResetDeliveredTodayCommand (Plan 3) + ResetMonthlyCountersCommand (Task 5 этого плана) + handleInsufficientBalance UPDATE (Task 6) + CsvReconcileJob INSERT/SELECT supplier_csv_reconcile_log + supplier_leads (Task 8). ✓
  • Default pgsql для tenant-scoped: LedgerService inserts (lead_charges + balance_transactions + supplier_lead_costs) — внутри уже открытой DB::transaction с SET LOCAL app.current_tenant_id от родительского RouteSupplierLeadJob::createDealCopyForProject. ✓

5. Известные ограничения (повторное упоминание из §8 spec'а):

  • LeadRouter::matchEligibleProjects is_active filter — будет проверено грепом в Task 4 Step 5 (если failing test ловит, значит фильтр работает; иначе — добавим).
  • config('services.supplier.alert_email') — явно добавляется в Task 8 Step 1.
  • Vue <v-data-table> font-feature-settings — Task 9/10/11 используют scoped CSS :deep(td) который должен сработать; если не сработает — fallback на <template #item.X> slot.

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-11-plan4-billing-csv-admin-plan.md.

12 Tasks в 3 фазах (Billing core 1-4 → Operations 5-8 → UI 9-11 → Verification 12). Атомарные коммиты, TDD на каждом Task.

Два варианта исполнения:

1. Subagent-Driven (recommended) — я диспатчу свежий subagent на каждый Task через superpowers:subagent-driven-development, делаю двух-stage review между Tasks. Быстрая итерация, изолированные контексты, легче дебажить.

2. Inline Execution — выполнение Tasks в текущей сессии через superpowers:executing-plans, batch'ами с checkpoint'ами для ревью.

Какой вариант предпочитаешь?