# Единое поведение двух балансовых замков (F/J) — план реализации

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development per task. Steps use checkbox (`- [ ]`) syntax.

**Goal:** Свести клиентскую заморозку (`tenants.frozen_by_balance_at`) и проектный блок (`projects.preflight_blocked_at`) к единому согласованному поведению: один расчёт «хватает ли денег» (полный лимит, Чтение А), пополнение снимает оба замка сразу, вечерний пересчёт 18:00 снимает блоки проектов у незаморожённых.

**Architecture:** Единый источник расчёта required — `Tenant::requiredLeadsForTomorrow()` (переводим на полный лимит). Единый путь разблокировки «разморозить клиента + снять блоки проектов» — расширенный `ProjectBlockReleaseService::releaseForTenant`, который зовут и `BillingTopupService::topup` (уже зовёт), и `BalancePreflightSweepJob` (делегирует passes-ветку). Заморозка (active→frozen) остаётся в sweep.

**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL (pgsql + pgsql_supplier BYPASSRLS).

**Спека:** `docs/superpowers/specs/2026-06-23-balance-lock-unify-FJ-design.md`

**Команды:** тесты — из каталога `app/`: `php artisan test --filter=<NAME>`; форматтер — `vendor/bin/pint <path>`.

---

## Task 1: Единый расчёт required = полный лимит (D2, откат R-19)

**Files:**
- Modify: `app/tests/Feature/Billing/TenantPreflightTest.php` (тест шеринга 58–74)
- Modify: `app/app/Models/Tenant.php:92-155` (`requiredLeadsForTomorrow`) + импорт `DB`

- [ ] **Step 1: Переписать падающий тест шеринга на полный лимит**

Заменить тест `R-19 3 tenants sharing same call source...` так, чтобы каждый тенант давал ПОЛНЫЙ лимит (10), а не долю (4). Обновить и предшествующий комментарий-блок R-19 на «откат R-19 → полный лимит (решение владельца 23.06)».

```php
it('returns full daily_limit_target even when 3 tenants share one call source (full-limit policy)', function () {
    $sharedPhone = '7929'.Str::random(7);
    $tenants = [];
    foreach (range(1, 3) as $i) {
        $t = Tenant::factory()->create(['balance_rub' => '1000.00']);
        Project::factory()->for($t)->asCallSignal($sharedPhone)->create([
            'is_active' => true,
            'daily_limit_target' => 10,
        ]);
        $tenants[] = $t;
    }
    // Единый расчёт по полному лимиту (D2): доля группы НЕ учитывается → 10, а не 4.
    expect($tenants[0]->fresh()->requiredLeadsForTomorrow())->toBe(10);
});
```

- [ ] **Step 2: Прогнать — должен упасть**

Run: `php artisan test --filter=TenantPreflightTest`
Expected: FAIL (ожидали 10, получили 4 — share-aware ещё активен).

- [ ] **Step 3: Перевести `requiredLeadsForTomorrow` на полный лимит**

Заменить тело метода (строки 92–155) на простую сумму и убрать неиспользуемый импорт `use Illuminate\Support\Facades\DB;` (строка 13):

```php
    public function requiredLeadsForTomorrow(): int
    {
        // D2 (23.06.2026): единый расчёт по ПОЛНОМУ лимиту активных проектов.
        // Откат share-aware (R-19) — решение владельца: один расчёт во всех точках
        // (заморозка/разморозка, снятие проектного блока). См. спеку balance-lock-unify-FJ.
        return (int) $this->projects()
            ->where('is_active', true)
            ->sum('daily_limit_target');
    }
```

- [ ] **Step 4: Прогнать — GREEN**

Run: `php artisan test --filter=TenantPreflightTest`
Expected: PASS (6/6).

- [ ] **Step 5: Pint**

Run: `vendor/bin/pint app/Models/Tenant.php`

---

## Task 2: Сервис разблокировки снимает оба замка + пополнение (B, D3, D6)

**Files:**
- Modify: `app/tests/Feature/Billing/ProjectBlockReleaseOnTopupTest.php` (+2 теста)
- Rewrite: `app/app/Services/Billing/ProjectBlockReleaseService.php`

- [ ] **Step 1: Добавить падающие тесты пополнения (разморозка + блок вместе)**

Дописать в конец `ProjectBlockReleaseOnTopupTest.php` (нужен `Mail::fake()` в этих тестах; добавить `use Illuminate\Support\Facades\Mail;` и `use App\Mail\BalanceUnfrozenMail;` к шапке файла):

```php
it('unfreezes tenant AND releases blocked project when topup covers the full order', function () {
    Mail::fake();
    $tenant = Tenant::factory()->withRequisites()->create([
        'balance_rub' => '0.00',
        'frozen_by_balance_at' => now()->subDay(),
    ]);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true,
        'daily_limit_target' => 30,
        'preflight_blocked_at' => now(),
    ]);

    // 2000₽ / 50 = 40 capacity >= 30 → снять оба замка сразу.
    app(BillingTopupService::class)->topup($tenant->id, '2000.00', null);

    expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
    expect($project->fresh()->preflight_blocked_at)->toBeNull();
    Mail::assertQueued(BalanceUnfrozenMail::class);
});

it('keeps BOTH locks when topup still insufficient', function () {
    Mail::fake();
    $tenant = Tenant::factory()->withRequisites()->create([
        'balance_rub' => '0.00',
        'frozen_by_balance_at' => now()->subDay(),
    ]);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true,
        'daily_limit_target' => 30,
        'preflight_blocked_at' => now(),
    ]);

    // 500₽ / 50 = 10 capacity < 30 → не трогать ни один замок.
    app(BillingTopupService::class)->topup($tenant->id, '500.00', null);

    expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
    expect($project->fresh()->preflight_blocked_at)->not->toBeNull();
    Mail::assertNotQueued(BalanceUnfrozenMail::class);
});
```

- [ ] **Step 2: Прогнать — должны упасть**

Run: `php artisan test --filter=ProjectBlockReleaseOnTopupTest`
Expected: FAIL на новых (topup пока не размораживает клиента).

- [ ] **Step 3: Переписать `ProjectBlockReleaseService` на единый путь разблокировки**

Полное новое содержимое файла:

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing;

use App\Jobs\SyncSupplierProjectJob;
use App\Mail\BalanceUnfrozenMail;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Supplier\SupplierExportMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;

/**
 * Единый путь разблокировки по балансу (F/J, 23.06.2026): снимает ОБА замка —
 * клиентскую заморозку (tenants.frozen_by_balance_at) и проектный блок
 * (projects.preflight_blocked_at) — по политике «всё-или-ничего».
 *
 * Расчёт required — единый, через Tenant::requiredLeadsForTomorrow() (полный лимит).
 * Вызывается из BillingTopupService::topup (пополнение, B) и из
 * BalancePreflightSweepJob (вечерний пересчёт 18:00, passes-ветка, F).
 *
 * Иерархия (J): заморозка — главный замок. Если хватает (passes) — размораживаем
 * клиента (если был заморожен) и снимаем блоки всех проектов; если не хватает —
 * не трогаем ничего (заморозку ставит сам sweep отдельно).
 */
final class ProjectBlockReleaseService
{
    public function __construct(
        private readonly BalancePreflightService $preflight = new BalancePreflightService,
    ) {}

    /**
     * @return array{released:int, unfrozen:bool, passes:bool, required_leads:int, capacity_leads:int, deficit_leads:int}
     */
    public function releaseForTenant(int $tenantId): array
    {
        $none = ['released' => 0, 'unfrozen' => false, 'passes' => true, 'required_leads' => 0, 'capacity_leads' => 0, 'deficit_leads' => 0];

        $tiers = PricingTier::query()->where('is_active', true)->get();
        if ($tiers->isEmpty()) {
            return $none; // биллинг не настроен — нечего пересчитывать.
        }

        $tenant = Tenant::find($tenantId);
        if ($tenant === null) {
            return $none;
        }

        // Единый расчёт (D2): полный лимит активных проектов.
        $required = $tenant->requiredLeadsForTomorrow();
        $result = $this->preflight->evaluate(
            balanceRub: (string) $tenant->balance_rub,
            deliveredInMonth: (int) $tenant->delivered_in_month,
            requiredLeads: $required,
            tiers: $tiers,
        );

        $wasFrozen = $tenant->frozen_by_balance_at !== null;

        if (! $result->passes) {
            // Не хватает на весь заказ — не снимаем ни один замок (всё-или-ничего).
            return [
                'released' => 0,
                'unfrozen' => false,
                'passes' => false,
                'required_leads' => $required,
                'capacity_leads' => $result->capacityLeads,
                'deficit_leads' => $result->deficitLeads,
            ];
        }

        // passes → снять оба замка.
        // 1. Разморозка клиента (если был заморожен): зеркалит sweep frozen→active.
        if ($wasFrozen) {
            $frozenAtWas = $tenant->frozen_by_balance_at;
            $tenant->frozen_by_balance_at = null;
            $tenant->save();

            DB::connection('pgsql_supplier')->table('projects')
                ->where('tenant_id', $tenant->id)
                ->where('paused_at', '>=', $frozenAtWas)
                ->update(['paused_at' => null]);

            DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
                'tenant_id' => $tenant->id,
                'event_type' => 'unfrozen',
                'triggered_by' => 'balance_release',
                'balance_rub_at_event' => $tenant->balance_rub,
                'required_leads' => $result->requiredLeads,
                'capacity_leads' => $result->capacityLeads,
                'total_daily_limit' => $result->requiredLeads,
                'details' => json_encode(['deficit_leads' => $result->deficitLeads]),
                'created_at' => now(),
            ]);

            Mail::queue(new BalanceUnfrozenMail($tenant, $result));
        }

        // 2. Снятие блоков проектов.
        $releasedIds = [];
        $blocked = Project::where('tenant_id', $tenantId)
            ->where('is_active', true)
            ->whereNotNull('preflight_blocked_at')
            ->get(['id']);
        foreach ($blocked as $p) {
            Project::where('id', $p->id)->update(['preflight_blocked_at' => null]);
            $releasedIds[] = (int) $p->id;
        }

        // 3. Синк к поставщику.
        if ($wasFrozen) {
            // Разморозка: пересинк всех активных незаблокированных проектов (зеркало sweep, online-gated).
            if (SupplierExportMode::isOnline()) {
                $ids = Project::where('tenant_id', $tenantId)
                    ->where('is_active', true)
                    ->whereNull('preflight_blocked_at')
                    ->pluck('id');
                foreach ($ids as $id) {
                    SyncSupplierProjectJob::dispatch((int) $id);
                }
            }
        } else {
            // Только снятие блока: синк снятых проектов (как было в E).
            foreach ($releasedIds as $id) {
                SyncSupplierProjectJob::dispatch($id);
            }
        }

        return [
            'released' => count($releasedIds),
            'unfrozen' => $wasFrozen,
            'passes' => true,
            'required_leads' => $required,
            'capacity_leads' => $result->capacityLeads,
            'deficit_leads' => 0,
        ];
    }
}
```

- [ ] **Step 4: Прогнать топап-тесты — GREEN**

Run: `php artisan test --filter=ProjectBlockReleaseOnTopupTest`
Expected: PASS (4 старых + 2 новых).

- [ ] **Step 5: Pint**

Run: `vendor/bin/pint app/Services/Billing/ProjectBlockReleaseService.php`

---

## Task 3: Вечерний пересчёт 18:00 снимает блоки проектов + иерархия (F, J)

**Files:**
- Modify: `app/tests/Feature/Billing/BalancePreflightSweepJobTest.php` (+3 теста)
- Modify: `app/app/Jobs/Billing/BalancePreflightSweepJob.php:53-118` (`evaluateTenant`)

- [ ] **Step 1: Добавить падающие тесты пересчёта (F + J)**

Дописать в конец `BalancePreflightSweepJobTest.php`:

```php
// F/J (23.06.2026): пересчёт 18:00 снимает блоки проектов у НЕзаморожённых;
// у заморожённых — не трогает (иерархия: заморозка главнее).

it('releases project block for active tenant when balance covers full order', function () {
    Mail::fake();
    // 2000₽ / 50 = 40 capacity >= 30 → клиент активен, блок проекта снять.
    $tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => null]);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true,
        'daily_limit_target' => 30,
        'preflight_blocked_at' => now(),
    ]);

    (new BalancePreflightSweepJob)->handle();

    expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
    expect($project->fresh()->preflight_blocked_at)->toBeNull();
});

it('does NOT release project block while tenant stays frozen (insufficient balance)', function () {
    Mail::fake();
    // 0₽ → не хватает: клиент остаётся заморожен, блок проекта НЕ снимаем.
    $tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => now()->subDay()]);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true,
        'daily_limit_target' => 30,
        'preflight_blocked_at' => now(),
    ]);

    (new BalancePreflightSweepJob)->handle();

    expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
    expect($project->fresh()->preflight_blocked_at)->not->toBeNull();
});

it('unfreezes tenant AND releases project block together when balance covers', function () {
    Mail::fake();
    // 2000₽ / 50 = 40 >= 30 → разморозить клиента И снять блок проекта в одном прогоне.
    $tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true,
        'daily_limit_target' => 30,
        'preflight_blocked_at' => now(),
    ]);

    (new BalancePreflightSweepJob)->handle();

    expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
    expect($project->fresh()->preflight_blocked_at)->toBeNull();
});
```

- [ ] **Step 2: Прогнать — новые падают**

Run: `php artisan test --filter=BalancePreflightSweepJobTest`
Expected: FAIL на новых (sweep пока не снимает проектный блок).

- [ ] **Step 3: Делегировать passes-ветку `evaluateTenant` сервису**

Заменить тело `evaluateTenant` (строки 53–118): freeze-ветку оставить, unfreeze-ветку убрать, при passes — звать сервис. Итог:

```php
    private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
    {
        // Spec C deploy hotfix (25.05.2026): фоновые джобы не проходят через
        // SetTenantContext → app.current_tenant_id не выставлен → RLS на projects
        // падает. Зеркалим SetTenantContext: SET LOCAL внутри транзакции (PgBouncer-safe).
        DB::transaction(function () use ($tenant, $service, $tiers): void {
            DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);

            $required = $tenant->requiredLeadsForTomorrow();
            $result = $service->evaluate(
                balanceRub: (string) $tenant->balance_rub,
                deliveredInMonth: (int) $tenant->delivered_in_month,
                requiredLeads: $required,
                tiers: $tiers,
            );

            $isFrozen = $tenant->frozen_by_balance_at !== null;

            if (! $result->passes) {
                // Переход active → frozen (разморозку/снятие блоков здесь НЕ делаем).
                if (! $isFrozen) {
                    $freezeAt = now();
                    $tenant->frozen_by_balance_at = $freezeAt;
                    $tenant->save();

                    // Stage 3 R-13 (spec §4.3.2): помечаем непаузнутые проекты моментом
                    // заморозки — SupplierSnapshotGuard не даст сменить источник, пока
                    // хвост слепка ещё может прилететь.
                    DB::connection('pgsql_supplier')->table('projects')
                        ->where('tenant_id', $tenant->id)
                        ->whereNull('paused_at')
                        ->update(['paused_at' => $freezeAt]);

                    $this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
                    Mail::queue(new BalanceFrozenMail($tenant, $result));
                    $this->dispatchSupplierSyncIfOnline($tenant);
                }

                return; // заморожен и не хватает — стабильное состояние, блоки не трогаем.
            }

            // passes → единый путь разблокировки (J): разморозить клиента (если был) +
            // снять блоки всех проектов (F). Идемпотентно: нет замков → no-op.
            (new ProjectBlockReleaseService)->releaseForTenant($tenant->id);
        });
    }
```

Также: удалить ставший неиспользуемым приватный метод-ветку, если осталась мёртвая `BalanceUnfrozenMail`-логика; импорты `BalanceUnfrozenMail` и `PreflightResult` оставить только если ещё используются (`logEvent` использует `PreflightResult` — оставить; `BalanceUnfrozenMail` больше не нужен в sweep — убрать импорт). Добавить `use App\Services\Billing\ProjectBlockReleaseService;`.

- [ ] **Step 4: Прогнать sweep-тесты — GREEN**

Run: `php artisan test --filter=BalancePreflightSweepJobTest`
Expected: PASS (старые регрессы + 3 новых).

- [ ] **Step 5: Pint**

Run: `vendor/bin/pint app/Jobs/Billing/BalancePreflightSweepJob.php`

---

## Task 4: Регрессия биллинга

- [ ] **Step 1: Прогнать связанные сьюты**

Run: `php artisan test --filter="Billing"`
Expected: PASS (все биллинг-тесты, вкл. BalancePreflightServiceTest, ProjectPreflightTest, ProjectBulkLimitPreflightTest, ProjectBlockReleaseOnTopupTest, BalancePreflightSweepJobTest, TenantPreflightTest, BalanceFrozenReminderJobTest).

- [ ] **Step 2: Pint полный по затронутым**

Run: `vendor/bin/pint app/Models/Tenant.php app/Services/Billing/ProjectBlockReleaseService.php app/Jobs/Billing/BalancePreflightSweepJob.php`
Expected: PASS / no changes.

---

## Self-review (после написания плана)

- **D2** → Task 1. **D3 (B)** → Task 2. **D4 (F)** → Task 3 (тест 1). **D5 (J)** → Task 3 (тесты 2,3 + sweep return при frozen). **D6 (общий путь)** → Task 2 (сервис) + Task 3 (sweep делегирует). **D7** → Task 4.
- Имена/сигнатуры консистентны: `requiredLeadsForTomorrow()`, `releaseForTenant(int): array`, ключ `unfrozen` добавлен (потребители return-значения нет, кроме топапа, который игнорирует).
- Плейсхолдеров нет: код приведён целиком в каждом шаге.
