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>
173 KiB
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 1–4): schema delta v8.18→v8.19, PricingTierResolver (pure), LedgerService с dual-balance prepaid-first логикой, integration в RouteSupplierLeadJob. Фаза II — Operations (Tasks 5–8): monthly reset cron, auto-pause flow с email rate-limit, CSV reconcile через расширение SupplierPortalClient + новый CsvReconcileJob hourly. Фаза III — UI (Tasks 9–12): 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.1–14 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 (+
handleInsufficientBalanceprivate 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 -
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:
- Verify: full Pest / Vitest / Pa11y / lefthook / lychee / Histoire build
- Modify: CLAUDE.md (через
claude-md-management:revise-claude-md) - Modify: docs/Открытые_вопросы_v8_3.md — +7 новых Биз-вопросов из spec §7.6
Это финальный 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
Открыть в браузере:
http://localhost:8000/admin/pricing-tiers— увидеть 7 ступеней.- Нажать «Редактировать сетку» → изменить tier 1 price → «Сохранить» → увидеть в «Запланированные изменения».
http://localhost:8000/admin/supplier-prices— увидеть 3 строки B1/B2/B3.http://localhost:8000/billing→ tab «Списания» — увидеть пустой список (если seed без charges) или 1 строку из smoke-теста.- Нажать «Скачать 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). ✓InsufficientBalanceException—priceKopecks: 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_supplierBYPASSRLS-роль используется единообразно в: RouteSupplierLeadJobself::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::matchEligibleProjectsis_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'ами для ревью.
Какой вариант предпочитаешь?