# Онлайн-пополнение баланса через ЮKassa (Часть 1+2) — 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:** Подключить реальный приём онлайн-оплаты ЮKassa (карта/СБП-QR) с автозачислением рублёвого баланса по webhook, спрятанный за флагом — прод продолжает работать на текущей заглушке до закрытия Б-1.

**Architecture:** Тонкий драйвер `YooKassaDriver` (HTTP к API ЮKassa) за интерфейсом `PaymentGatewayDriver`; `OnlineTopupService` создаёт строку `saas_transactions(pending)` и платёж в ЮKassa; `PaymentWebhookController` идемпотентно сверяет платёж server-to-server и зачисляет баланс через существующий `BillingTopupService`. Развилка «заглушка ↔ шлюз» — по флагу `billing_yookassa_enabled` в `system_settings`.

**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (RLS), Pest 4, Vue 3 + Vuetify 3 + Vitest. HTTP — `Illuminate\Support\Facades\Http`. Шифрование ключей — `Crypt`.

**Спека:** [docs/superpowers/specs/2026-06-22-billing-online-topup-yookassa-design.md](../specs/2026-06-22-billing-online-topup-yookassa-design.md)

---

## Предусловия и факты о кодовой базе (прочитать перед стартом)

- **Таблицы уже существуют.** `db/schema.sql` грузится целиком в `database/migrations/0001_01_01_000000_load_initial_schema.php` через `DB::unprepared()`. Таблицы `legal_entities`, `payment_gateways`, `saas_transactions` уже в БД — **новые миграции под них НЕ создаём**. Нужны только Eloquent-модели.
- **Значения статусов (канон schema.sql:2461):** `saas_transactions.status ∈ {pending, success, failed, refunded}`; `type ∈ {topup, refund, manual_credit, manual_debit}`. Используем именно `success` (НЕ `succeeded`).
- **RLS уже включён** на `saas_transactions` (schema.sql:3081 + policy `tenant_isolation` 3120). Webhook исполняется вне tenant-сессии → работаем через `DB::table()` с явным `tenant_id` под ролью с BYPASSRLS (`crm_supplier_worker`, как джобы) ИЛИ устанавливаем `app.current_tenant_id` в транзакции. План использует явный tenant scope + установку GUC (см. Task 7).
- **Зачисление денег НЕ дублируем** — переиспользуем `App\Services\Billing\BillingTopupService::topup(int $tenantId, string $amountRub, ?int $userId): BalanceTransaction` (app/app/Services/Billing/BillingTopupService.php). Он сам делает `lockForUpdate` + append-only ledger.
- **Образец webhook-идемпотентности и IP-allowlist** — `app/app/Http/Controllers/Api/SupplierWebhookController.php` (hash_equals, IpUtils, `webhook_log`, 404 на несовпадение).
- **Флаги читаются** из `system_settings` через `DB::table('system_settings')->where('key', ...)`.
- **tenants.contact_email** (schema.sql:651) — email клиента для чека 54-ФЗ.
- **Правка `db/schema.sql`** (seed-флаги в Task 2) ТРЕБУЕТ записи в `db/CHANGELOG_schema.md` (правило §4.2). RLS-review не нужен (таблицы/политики не меняем, только seed-строки).
- **Команды:** тесты `cd app && composer test`, один тест `cd app && ./vendor/bin/pest --filter=ИмяТеста`, фронт `cd app && npm run test:vue`.
- **Все коммиты — под стеной «роутер-наставник»** (owner-escape для каждого коммита). Сообщения без круглых скобок.

---

## Файловая структура

**Создаём:**
- `app/app/Models/LegalEntity.php` — модель юрлица.
- `app/app/Models/PaymentGateway.php` — модель шлюза + расшифровка `config`.
- `app/app/Models/SaasTransaction.php` — модель платежа.
- `app/app/Support/SystemSettings.php` — хелпер чтения флагов/значений из system_settings.
- `app/app/Services/Billing/Gateway/PaymentGatewayDriver.php` — интерфейс драйвера.
- `app/app/Services/Billing/Gateway/CreatePaymentResult.php` — DTO ответа создания платежа.
- `app/app/Services/Billing/Gateway/WebhookVerifyResult.php` — DTO результата сверки.
- `app/app/Services/Billing/Gateway/YooKassaDriver.php` — драйвер ЮKassa.
- `app/app/Services/Billing/Gateway/PaymentGatewayManager.php` — резолвер активного драйвера.
- `app/app/Services/Billing/OnlineTopupService.php` — оркестрация создания платежа.
- `app/app/Http/Controllers/Api/PaymentWebhookController.php` — приём webhook.
- `app/app/Http/Controllers/Api/AdminPaymentGatewayController.php` — ввод ключей в админке.
- Тесты Pest: `app/tests/Unit/Billing/...`, `app/tests/Feature/Billing/...`.
- Фронт: правки `app/resources/js/components/.../TopupDialog.vue`, `app/resources/js/views/BillingView.vue`, `app/resources/js/api/billing.ts` + Vitest.

**Модифицируем:**
- `app/app/Http/Controllers/Api/BillingController.php:45` — развилка флага в `topup()`.
- `app/routes/web.php` — webhook-роут + админ-роуты шлюза.
- `db/schema.sql` (seed system_settings, ~строка 2960) + `db/CHANGELOG_schema.md`.

---

## ЧАСТЬ 1 — ФУНДАМЕНТ

### Task 1: Eloquent-модели для существующих таблиц

**Files:**
- Create: `app/app/Models/LegalEntity.php`
- Create: `app/app/Models/PaymentGateway.php`
- Create: `app/app/Models/SaasTransaction.php`
- Test: `app/tests/Unit/Billing/PaymentGatewayModelTest.php`

- [ ] **Step 1: Написать падающий тест на расшифровку config у PaymentGateway**

`app/tests/Unit/Billing/PaymentGatewayModelTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\PaymentGateway;
use Illuminate\Support\Facades\Crypt;

it('расшифровывает config в массив через accessor', function () {
    $gw = new PaymentGateway;
    $gw->config = Crypt::encrypt(['shop_id' => '123', 'secret_key' => 'test_secret']);

    expect($gw->credentials())->toBe(['shop_id' => '123', 'secret_key' => 'test_secret']);
});

it('возвращает пустой массив если config пустой', function () {
    $gw = new PaymentGateway;
    $gw->config = '';

    expect($gw->credentials())->toBe([]);
});
```

- [ ] **Step 2: Запустить тест — убедиться, что падает**

Run: `cd app && ./vendor/bin/pest --filter=PaymentGatewayModelTest`
Expected: FAIL — class `App\Models\PaymentGateway` not found.

- [ ] **Step 3: Создать три модели**

`app/app/Models/LegalEntity.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
 * Юрлицо-получатель платежей (schema.sql table legal_entities).
 * Без RLS — общесистемный справочник.
 *
 * @mixin IdeHelperLegalEntity
 */
class LegalEntity extends Model
{
    public $timestamps = false;

    protected $fillable = [
        'code', 'name', 'short_name', 'legal_form', 'inn', 'kpp', 'ogrn',
        'okpo', 'legal_address', 'actual_address', 'bank_name', 'bank_account',
        'bank_bik', 'bank_corr', 'director_name', 'director_post',
        'director_basis', 'vat_mode',
    ];
}
```

`app/app/Models/PaymentGateway.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;

/**
 * Платёжный шлюз (schema.sql table payment_gateways).
 * `config` хранится ЗАШИФРОВАННЫМ (Crypt::encrypt JSON {shop_id, secret_key}).
 * Без RLS — общесистемная таблица настроек.
 *
 * @mixin IdeHelperPaymentGateway
 */
class PaymentGateway extends Model
{
    public $timestamps = false;

    protected $fillable = [
        'code', 'name', 'driver', 'legal_entity_id', 'config', 'is_active',
        'accepts_methods', 'min_amount_rub', 'max_amount_rub', 'sort_order',
    ];

    protected function casts(): array
    {
        return [
            'is_active' => 'boolean',
            'accepts_methods' => 'array',
            'min_amount_rub' => 'decimal:2',
            'max_amount_rub' => 'decimal:2',
        ];
    }

    /** Расшифрованные креденшелы шлюза; [] если config пустой/битый. */
    public function credentials(): array
    {
        if ($this->config === null || $this->config === '') {
            return [];
        }

        try {
            $decoded = Crypt::decrypt($this->config);
        } catch (\Throwable) {
            return [];
        }

        return is_array($decoded) ? $decoded : [];
    }
}
```

`app/app/Models/SaasTransaction.php`:

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
 * Платёж SaaS (schema.sql table saas_transactions). RLS tenant_isolation.
 * status ∈ {pending, success, failed, refunded}; type ∈ {topup, refund, manual_credit, manual_debit}.
 *
 * @mixin IdeHelperSaasTransaction
 */
class SaasTransaction extends Model
{
    public const CREATED_AT = 'created_at';

    public const UPDATED_AT = null;

    public const STATUS_PENDING = 'pending';

    public const STATUS_SUCCESS = 'success';

    public const STATUS_FAILED = 'failed';

    public const TYPE_TOPUP = 'topup';

    protected $fillable = [
        'tenant_id', 'type', 'amount_rub', 'balance_rub_after', 'leads_credited',
        'gateway_id', 'gateway_code', 'gateway_payment_id', 'gateway_idempotence_key',
        'payment_method', 'legal_entity_id', 'invoice_id', 'upd_id', 'status',
        'description', 'failure_reason', 'created_at', 'completed_at',
    ];

    protected function casts(): array
    {
        return [
            'amount_rub' => 'decimal:2',
            'balance_rub_after' => 'decimal:2',
            'created_at' => 'datetime',
            'completed_at' => 'datetime',
        ];
    }
}
```

- [ ] **Step 4: Запустить тест — убедиться, что проходит**

Run: `cd app && ./vendor/bin/pest --filter=PaymentGatewayModelTest`
Expected: PASS (2 passed).

- [ ] **Step 5: Коммит**

```bash
git add app/app/Models/LegalEntity.php app/app/Models/PaymentGateway.php app/app/Models/SaasTransaction.php app/tests/Unit/Billing/PaymentGatewayModelTest.php
git commit -m "feat(billing): Eloquent-модели legal_entities/payment_gateways/saas_transactions"
```

---

### Task 2: Флаги в system_settings + хелпер чтения

**Files:**
- Modify: `db/schema.sql` (seed-блок INSERT INTO system_settings, ~строка 2960)
- Modify: `db/CHANGELOG_schema.md`
- Create: `app/app/Support/SystemSettings.php`
- Test: `app/tests/Unit/Billing/SystemSettingsHelperTest.php`

- [ ] **Step 1: Написать падающий тест хелпера**

`app/tests/Unit/Billing/SystemSettingsHelperTest.php`:

```php
<?php

declare(strict_types=1);

use App\Support\SystemSettings;
use Illuminate\Support\Facades\DB;

it('читает bool-флаг из system_settings', function () {
    DB::table('system_settings')->updateOrInsert(
        ['key' => 'billing_yookassa_enabled'],
        ['value' => 'true', 'type' => 'bool', 'updated_at' => now()]
    );

    expect(SystemSettings::bool('billing_yookassa_enabled'))->toBeTrue();
});

it('возвращает default если ключа нет', function () {
    DB::table('system_settings')->where('key', 'no_such_flag_xyz')->delete();

    expect(SystemSettings::bool('no_such_flag_xyz', false))->toBeFalse();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && ./vendor/bin/pest --filter=SystemSettingsHelperTest`
Expected: FAIL — class `App\Support\SystemSettings` not found.

- [ ] **Step 3: Создать хелпер**

`app/app/Support/SystemSettings.php`:

```php
<?php

declare(strict_types=1);

namespace App\Support;

use Illuminate\Support\Facades\DB;

/**
 * Тонкий read-хелпер для общесистемных настроек (table system_settings).
 * Значения хранятся строками; type — подсказка для UI/каста.
 */
final class SystemSettings
{
    public static function get(string $key, ?string $default = null): ?string
    {
        $row = DB::table('system_settings')->where('key', $key)->first();

        return $row === null ? $default : (string) $row->value;
    }

    public static function bool(string $key, bool $default = false): bool
    {
        $val = self::get($key);
        if ($val === null) {
            return $default;
        }

        return in_array(strtolower($val), ['1', 'true', 'yes', 'on'], true);
    }
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && ./vendor/bin/pest --filter=SystemSettingsHelperTest`
Expected: PASS (2 passed).

- [ ] **Step 5: Добавить seed-флаги в schema.sql**

В `db/schema.sql` в существующий блок `INSERT INTO system_settings (key, value, type, description) VALUES` (около строки 2960) добавить две строки (соблюдая синтаксис существующих строк блока — запятая/точка с запятой в конце):

```sql
    ('billing_yookassa_enabled', 'false', 'bool', 'Рубильник онлайн-оплаты ЮKassa: false=мгновенная заглушка, true=реальный шлюз'),
    ('billing_receipt_enabled',  'false', 'bool', 'Передавать чек 54-ФЗ в ЮKassa (включать после подключения ОФД)'),
```

- [ ] **Step 6: Запись в CHANGELOG_schema.md**

В `db/CHANGELOG_schema.md` добавить запись (формат — по образцу последних записей файла): дата 2026-06-22, «seed: добавлены флаги `billing_yookassa_enabled` и `billing_receipt_enabled` в system_settings (дефолт false) — рубильник онлайн-оплаты ЮKassa. Таблицы/политики не менялись.»

- [ ] **Step 7: Перезагрузить тестовую БД и прогнать тест ещё раз**

Run: `cd app && php artisan migrate:fresh --env=testing && ./vendor/bin/pest --filter=SystemSettingsHelperTest`
Expected: PASS — флаги сидируются из schema.sql.

- [ ] **Step 8: Коммит**

```bash
git add app/app/Support/SystemSettings.php app/tests/Unit/Billing/SystemSettingsHelperTest.php db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(billing): флаги billing_yookassa_enabled/billing_receipt_enabled + хелпер SystemSettings"
```

---

### Task 3: Интерфейс драйвера + DTO

**Files:**
- Create: `app/app/Services/Billing/Gateway/CreatePaymentResult.php`
- Create: `app/app/Services/Billing/Gateway/WebhookVerifyResult.php`
- Create: `app/app/Services/Billing/Gateway/PaymentGatewayDriver.php`
- Test: `app/tests/Unit/Billing/GatewayDtoTest.php`

- [ ] **Step 1: Написать падающий тест DTO**

`app/tests/Unit/Billing/GatewayDtoTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Billing\Gateway\CreatePaymentResult;
use App\Services\Billing\Gateway\WebhookVerifyResult;

it('CreatePaymentResult хранит id платежа и url подтверждения', function () {
    $r = new CreatePaymentResult('pay_123', 'https://yoomoney.ru/checkout/pay_123');

    expect($r->gatewayPaymentId)->toBe('pay_123')
        ->and($r->confirmationUrl)->toBe('https://yoomoney.ru/checkout/pay_123');
});

it('WebhookVerifyResult различает успех и прочие статусы', function () {
    $ok = new WebhookVerifyResult('pay_123', 'succeeded', '500.00', 'card');
    $pending = new WebhookVerifyResult('pay_9', 'pending', '500.00', null);

    expect($ok->isSucceeded())->toBeTrue()
        ->and($pending->isSucceeded())->toBeFalse();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && ./vendor/bin/pest --filter=GatewayDtoTest`
Expected: FAIL — classes not found.

- [ ] **Step 3: Создать DTO и интерфейс**

`app/app/Services/Billing/Gateway/CreatePaymentResult.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing\Gateway;

/** Результат создания платежа в шлюзе. */
final class CreatePaymentResult
{
    public function __construct(
        public readonly string $gatewayPaymentId,
        public readonly string $confirmationUrl,
    ) {}
}
```

`app/app/Services/Billing/Gateway/WebhookVerifyResult.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing\Gateway;

/**
 * Итог server-to-server сверки платежа (НЕ доверяем телу webhook —
 * статус берётся из ответа GET /payments/{id} к шлюзу).
 */
final class WebhookVerifyResult
{
    public function __construct(
        public readonly string $gatewayPaymentId,
        public readonly string $status,        // 'succeeded'|'pending'|'canceled'|... (термины шлюза)
        public readonly string $amountRub,     // DECIMAL-строка scale 2
        public readonly ?string $paymentMethod, // 'card'|'sbp'|'yoo_money'|null
    ) {}

    /** В терминах ЮKassa оплачено = 'succeeded'. */
    public function isSucceeded(): bool
    {
        return $this->status === 'succeeded';
    }
}
```

`app/app/Services/Billing/Gateway/PaymentGatewayDriver.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing\Gateway;

use App\Models\PaymentGateway;

/** Контракт платёжного драйвера. Реализации: YooKassaDriver (далее — Tinkoff и т.п.). */
interface PaymentGatewayDriver
{
    /**
     * Создать платёж в шлюзе.
     *
     * @param  string  $amountRub  DECIMAL-строка scale 2 («500.00»).
     * @param  string  $idempotenceKey  UUID — защита от двойного создания.
     * @param  string  $returnUrl  Куда вернуть клиента после оплаты.
     * @param  array<string,mixed>|null  $receipt  Состав чека 54-ФЗ или null.
     */
    public function createPayment(
        PaymentGateway $gateway,
        string $amountRub,
        string $idempotenceKey,
        string $returnUrl,
        ?array $receipt,
    ): CreatePaymentResult;

    /**
     * Сверить платёж server-to-server по его id (статус из ответа шлюза, не из webhook).
     */
    public function verifyPayment(PaymentGateway $gateway, string $gatewayPaymentId): WebhookVerifyResult;
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && ./vendor/bin/pest --filter=GatewayDtoTest`
Expected: PASS (2 passed).

- [ ] **Step 5: Коммит**

```bash
git add app/app/Services/Billing/Gateway/
git add app/tests/Unit/Billing/GatewayDtoTest.php
git commit -m "feat(billing): интерфейс PaymentGatewayDriver + DTO результата платежа и сверки"
```

---

### Task 4: YooKassaDriver

API: `POST https://api.yookassa.ru/v3/payments` и `GET .../v3/payments/{id}`, Basic-auth `shopId:secretKey`, заголовок `Idempotence-Key`. Документация — context7/`yookassa.ru/developers`.

**Files:**
- Create: `app/app/Services/Billing/Gateway/YooKassaDriver.php`
- Test: `app/tests/Unit/Billing/YooKassaDriverTest.php`

- [ ] **Step 1: Написать падающий тест с Http::fake**

`app/tests/Unit/Billing/YooKassaDriverTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\PaymentGateway;
use App\Services\Billing\Gateway\YooKassaDriver;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;

function fakeGateway(): PaymentGateway
{
    $gw = new PaymentGateway;
    $gw->code = 'yookassa';
    $gw->config = Crypt::encrypt(['shop_id' => 'shop_1', 'secret_key' => 'test_secret']);

    return $gw;
}

it('создаёт платёж и возвращает id + confirmation_url', function () {
    Http::fake([
        'api.yookassa.ru/v3/payments' => Http::response([
            'id' => '2da2b...test',
            'status' => 'pending',
            'confirmation' => ['type' => 'redirect', 'confirmation_url' => 'https://yoomoney.ru/checkout/2da2b'],
        ], 200),
    ]);

    $res = (new YooKassaDriver)->createPayment(
        fakeGateway(), '500.00', 'b3f1c2d4-0000-4000-8000-000000000001', 'https://liderra.ru/billing', null
    );

    expect($res->gatewayPaymentId)->toBe('2da2b...test')
        ->and($res->confirmationUrl)->toBe('https://yoomoney.ru/checkout/2da2b');

    Http::assertSent(function ($request) {
        return $request->hasHeader('Idempotence-Key', 'b3f1c2d4-0000-4000-8000-000000000001')
            && $request['amount']['value'] === '500.00'
            && $request['amount']['currency'] === 'RUB'
            && $request['capture'] === true;
    });
});

it('сверяет платёж и распознаёт succeeded', function () {
    Http::fake([
        'api.yookassa.ru/v3/payments/pay_77' => Http::response([
            'id' => 'pay_77',
            'status' => 'succeeded',
            'amount' => ['value' => '1000.00', 'currency' => 'RUB'],
            'payment_method' => ['type' => 'bank_card'],
        ], 200),
    ]);

    $res = (new YooKassaDriver)->verifyPayment(fakeGateway(), 'pay_77');

    expect($res->isSucceeded())->toBeTrue()
        ->and($res->amountRub)->toBe('1000.00')
        ->and($res->paymentMethod)->toBe('bank_card');
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && ./vendor/bin/pest --filter=YooKassaDriverTest`
Expected: FAIL — class `YooKassaDriver` not found.

- [ ] **Step 3: Реализовать драйвер**

`app/app/Services/Billing/Gateway/YooKassaDriver.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing\Gateway;

use App\Models\PaymentGateway;
use Illuminate\Support\Facades\Http;
use RuntimeException;

/**
 * Драйвер ЮKassa (YooKassa API v3).
 * Auth: Basic shopId:secretKey. Idempotence-Key — заголовок при создании.
 * Чек 54-ФЗ передаётся секцией receipt (включается флагом billing_receipt_enabled).
 */
final class YooKassaDriver implements PaymentGatewayDriver
{
    private const BASE = 'https://api.yookassa.ru/v3';

    public function createPayment(
        PaymentGateway $gateway,
        string $amountRub,
        string $idempotenceKey,
        string $returnUrl,
        ?array $receipt,
    ): CreatePaymentResult {
        [$shopId, $secret] = $this->creds($gateway);

        $payload = [
            'amount' => ['value' => $amountRub, 'currency' => 'RUB'],
            'capture' => true,
            'confirmation' => ['type' => 'redirect', 'return_url' => $returnUrl],
            'description' => 'Пополнение баланса Лидерра',
        ];
        if ($receipt !== null) {
            $payload['receipt'] = $receipt;
        }

        $resp = Http::withBasicAuth($shopId, $secret)
            ->withHeaders(['Idempotence-Key' => $idempotenceKey])
            ->acceptJson()
            ->post(self::BASE.'/payments', $payload);

        if (! $resp->successful()) {
            throw new RuntimeException('YooKassa createPayment failed: HTTP '.$resp->status());
        }

        $id = (string) $resp->json('id');
        $url = (string) $resp->json('confirmation.confirmation_url');
        if ($id === '' || $url === '') {
            throw new RuntimeException('YooKassa createPayment: пустой id/confirmation_url');
        }

        return new CreatePaymentResult($id, $url);
    }

    public function verifyPayment(PaymentGateway $gateway, string $gatewayPaymentId): WebhookVerifyResult
    {
        [$shopId, $secret] = $this->creds($gateway);

        $resp = Http::withBasicAuth($shopId, $secret)
            ->acceptJson()
            ->get(self::BASE.'/payments/'.$gatewayPaymentId);

        if (! $resp->successful()) {
            throw new RuntimeException('YooKassa verifyPayment failed: HTTP '.$resp->status());
        }

        return new WebhookVerifyResult(
            gatewayPaymentId: (string) $resp->json('id'),
            status: (string) $resp->json('status'),
            amountRub: (string) $resp->json('amount.value'),
            paymentMethod: $resp->json('payment_method.type'),
        );
    }

    /** @return array{0:string,1:string} [shopId, secretKey] */
    private function creds(PaymentGateway $gateway): array
    {
        $c = $gateway->credentials();
        $shopId = (string) ($c['shop_id'] ?? '');
        $secret = (string) ($c['secret_key'] ?? '');
        if ($shopId === '' || $secret === '') {
            throw new RuntimeException('YooKassa: shop_id/secret_key не настроены');
        }

        return [$shopId, $secret];
    }
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && ./vendor/bin/pest --filter=YooKassaDriverTest`
Expected: PASS (2 passed).

- [ ] **Step 5: Коммит**

```bash
git add app/app/Services/Billing/Gateway/YooKassaDriver.php app/tests/Unit/Billing/YooKassaDriverTest.php
git commit -m "feat(billing): YooKassaDriver — создание платежа и server-to-server сверка"
```

---

## ЧАСТЬ 2 — ОНЛАЙН-ОПЛАТА

### Task 5: PaymentGatewayManager + OnlineTopupService

**Files:**
- Create: `app/app/Services/Billing/Gateway/PaymentGatewayManager.php`
- Create: `app/app/Services/Billing/OnlineTopupService.php`
- Test: `app/tests/Feature/Billing/OnlineTopupServiceTest.php`

- [ ] **Step 1: Написать падающий feature-тест**

`app/tests/Feature/Billing/OnlineTopupServiceTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Models\Tenant;
use App\Services\Billing\Gateway\CreatePaymentResult;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\OnlineTopupService;

it('создаёт saas_transactions(pending) и возвращает confirmation_url', function () {
    $tenant = Tenant::factory()->create();
    $gw = PaymentGateway::create([
        'code' => 'yookassa', 'name' => 'ЮKassa', 'driver' => 'yookassa',
        'legal_entity_id' => null, 'config' => '', 'is_active' => true,
        'accepts_methods' => ['card', 'sbp'], 'min_amount_rub' => '100.00',
    ]);

    $fakeDriver = Mockery::mock(PaymentGatewayDriver::class);
    $fakeDriver->shouldReceive('createPayment')->once()
        ->andReturn(new CreatePaymentResult('pay_abc', 'https://yoomoney.ru/checkout/pay_abc'));

    $service = new OnlineTopupService($fakeDriver);
    $res = $service->start($tenant->id, '500.00', $gw, 'https://liderra.ru/billing', userId: 7);

    expect($res->confirmationUrl)->toBe('https://yoomoney.ru/checkout/pay_abc');

    $tx = SaasTransaction::where('gateway_payment_id', 'pay_abc')->firstOrFail();
    expect($tx->status)->toBe('pending')
        ->and($tx->tenant_id)->toBe($tenant->id)
        ->and($tx->amount_rub)->toBe('500.00')
        ->and($tx->type)->toBe('topup');
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && ./vendor/bin/pest --filter=OnlineTopupServiceTest`
Expected: FAIL — class `OnlineTopupService` not found.

- [ ] **Step 3: Реализовать менеджер и сервис**

`app/app/Services/Billing/Gateway/PaymentGatewayManager.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing\Gateway;

use App\Models\PaymentGateway;
use RuntimeException;

/** Резолвер активного шлюза и его драйвера. */
final class PaymentGatewayManager
{
    public function activeGateway(): ?PaymentGateway
    {
        return PaymentGateway::where('is_active', true)->orderBy('sort_order')->first();
    }

    public function driverFor(PaymentGateway $gateway): PaymentGatewayDriver
    {
        return match ($gateway->driver) {
            'yookassa' => new YooKassaDriver,
            default => throw new RuntimeException('Неизвестный драйвер шлюза: '.$gateway->driver),
        };
    }
}
```

`app/app/Services/Billing/OnlineTopupService.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\Billing;

use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Services\Billing\Gateway\CreatePaymentResult;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use Illuminate\Support\Str;

/**
 * Оркестрация онлайн-пополнения: создаёт строку saas_transactions(pending) и
 * платёж в шлюзе. Зачисление баланса — позже, в PaymentWebhookController после
 * server-to-server сверки. Знает о шлюзе только через интерфейс драйвера.
 */
final class OnlineTopupService
{
    public function __construct(private readonly PaymentGatewayDriver $driver) {}

    public function start(
        int $tenantId,
        string $amountRub,
        PaymentGateway $gateway,
        string $returnUrl,
        ?int $userId,
    ): CreatePaymentResult {
        $idempotenceKey = (string) Str::uuid();

        $tx = SaasTransaction::create([
            'tenant_id' => $tenantId,
            'type' => SaasTransaction::TYPE_TOPUP,
            'amount_rub' => $amountRub,
            'gateway_id' => $gateway->id,
            'gateway_code' => $gateway->code,
            'gateway_idempotence_key' => $idempotenceKey,
            'legal_entity_id' => $gateway->legal_entity_id,
            'status' => SaasTransaction::STATUS_PENDING,
            'description' => 'Онлайн-пополнение баланса',
            'created_at' => now(),
        ]);

        $result = $this->driver->createPayment($gateway, $amountRub, $idempotenceKey, $returnUrl, null);

        $tx->gateway_payment_id = $result->gatewayPaymentId;
        $tx->save();

        return $result;
    }
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && ./vendor/bin/pest --filter=OnlineTopupServiceTest`
Expected: PASS (1 passed).

- [ ] **Step 5: Коммит**

```bash
git add app/app/Services/Billing/Gateway/PaymentGatewayManager.php app/app/Services/Billing/OnlineTopupService.php app/tests/Feature/Billing/OnlineTopupServiceTest.php
git commit -m "feat(billing): PaymentGatewayManager + OnlineTopupService — создание онлайн-платежа"
```

---

### Task 6: Развилка флага в BillingController@topup

**Files:**
- Modify: `app/app/Http/Controllers/Api/BillingController.php:45-69`
- Test: `app/tests/Feature/Billing/TopupFlagForkTest.php`

- [ ] **Step 1: Написать падающий feature-тест на обе ветки**

`app/tests/Feature/Billing/TopupFlagForkTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\PaymentGateway;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\Gateway\CreatePaymentResult;
use App\Services\Billing\Gateway\PaymentGatewayManager;
use App\Services\Billing\OnlineTopupService;
use Illuminate\Support\Facades\DB;

function actingTenantUser(): User
{
    $tenant = Tenant::factory()->create(['balance_rub' => '0.00']);

    return User::factory()->create(['tenant_id' => $tenant->id]);
}

it('флаг ВЫКЛ — старое мгновенное зачисление (заглушка)', function () {
    DB::table('system_settings')->updateOrInsert(
        ['key' => 'billing_yookassa_enabled'],
        ['value' => 'false', 'type' => 'bool', 'updated_at' => now()]
    );
    $user = actingTenantUser();

    $resp = $this->actingAs($user)->postJson('/api/billing/topup', ['amount_rub' => 500]);

    $resp->assertCreated()
        ->assertJsonStructure(['transaction', 'balance_rub'])
        ->assertJsonMissing(['confirmation_url' => null]);
    expect($resp->json('balance_rub'))->toBe('500.00');
});

it('флаг ВКЛ — возвращает confirmation_url, баланс не меняется до webhook', function () {
    DB::table('system_settings')->updateOrInsert(
        ['key' => 'billing_yookassa_enabled'],
        ['value' => 'true', 'type' => 'bool', 'updated_at' => now()]
    );
    PaymentGateway::create([
        'code' => 'yookassa', 'name' => 'ЮKassa', 'driver' => 'yookassa',
        'config' => '', 'is_active' => true, 'accepts_methods' => ['card'],
        'min_amount_rub' => '100.00',
    ]);
    $user = actingTenantUser();

    // Подменяем OnlineTopupService на фейк (createPayment не ходит в сеть)
    $this->mock(OnlineTopupService::class, function ($m) {
        $m->shouldReceive('start')->once()
            ->andReturn(new CreatePaymentResult('pay_x', 'https://yoomoney.ru/checkout/pay_x'));
    });

    $resp = $this->actingAs($user)->postJson('/api/billing/topup', ['amount_rub' => 500]);

    $resp->assertCreated()->assertJson(['confirmation_url' => 'https://yoomoney.ru/checkout/pay_x']);
    expect($user->fresh()->tenant->balance_rub)->toBe('0.00');
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && ./vendor/bin/pest --filter=TopupFlagForkTest`
Expected: FAIL — второй тест не получает `confirmation_url` (ветка флага ещё не реализована).

- [ ] **Step 3: Реализовать развилку в контроллере**

В `app/app/Http/Controllers/Api/BillingController.php` заменить метод `topup()` (строки 45-69). Добавить в `use`-блок: `use App\Services\Billing\Gateway\PaymentGatewayManager;`, `use App\Services\Billing\OnlineTopupService;`, `use App\Support\SystemSettings;`.

```php
    public function topup(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
        ]);

        /** @var User $user */
        $user = $request->user();
        $amountRub = bcadd((string) $validated['amount_rub'], '0', 2);

        // Развилка: реальный шлюз (флаг ВКЛ) ИЛИ мгновенная заглушка (флаг ВЫКЛ).
        if (SystemSettings::bool('billing_yookassa_enabled')) {
            $manager = app(PaymentGatewayManager::class);
            $gateway = $manager->activeGateway();
            if ($gateway === null) {
                return response()->json(['message' => 'Платёжный шлюз не настроен.'], 503);
            }

            $returnUrl = rtrim(config('app.url'), '/').'/billing?topup=return';
            $result = app(OnlineTopupService::class)->start(
                (int) $user->tenant_id, $amountRub, $gateway, $returnUrl, (int) $user->id
            );

            return response()->json(['confirmation_url' => $result->confirmationUrl], 201);
        }

        // Заглушка (текущее прод-поведение до Б-1): мгновенное зачисление.
        $tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id);

        return response()->json([
            'transaction' => [
                'id' => $tx->id,
                'type' => $tx->type,
                'amount_rub' => $tx->amount_rub,
                'balance_rub_after' => $tx->balance_rub_after,
                'created_at' => $tx->created_at,
            ],
            'balance_rub' => $tx->balance_rub_after,
        ], 201);
    }
```

> Примечание: `OnlineTopupService` резолвится через контейнер. Зарегистрировать биндинг в `app/app/Providers/AppServiceProvider.php` (метод `register`): `$this->app->bind(OnlineTopupService::class, fn ($app) => new OnlineTopupService($app->make(PaymentGatewayManager::class)->driverFor($app->make(PaymentGatewayManager::class)->activeGateway())));` — НО так как `activeGateway()` может быть null, безопаснее биндить `PaymentGatewayDriver` лениво. Реализация биндинга — Step 3b.

- [ ] **Step 3b: Зарегистрировать биндинг драйвера**

В `app/app/Providers/AppServiceProvider.php` метод `register()` добавить:

```php
        $this->app->bind(\App\Services\Billing\Gateway\PaymentGatewayDriver::class, function ($app) {
            $manager = $app->make(\App\Services\Billing\Gateway\PaymentGatewayManager::class);
            $gateway = $manager->activeGateway();
            if ($gateway === null) {
                throw new \RuntimeException('Нет активного платёжного шлюза');
            }

            return $manager->driverFor($gateway);
        });
```

`OnlineTopupService` получит `PaymentGatewayDriver` автоматически (constructor injection). В тесте Task 6 мы мокаем сам `OnlineTopupService`, поэтому биндинг драйвера там не вызывается.

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && ./vendor/bin/pest --filter=TopupFlagForkTest`
Expected: PASS (2 passed).

- [ ] **Step 5: Коммит**

```bash
git add app/app/Http/Controllers/Api/BillingController.php app/app/Providers/AppServiceProvider.php app/tests/Feature/Billing/TopupFlagForkTest.php
git commit -m "feat(billing): развилка topup по флагу billing_yookassa_enabled — шлюз vs заглушка"
```

---

### Task 7: PaymentWebhookController — идемпотентное зачисление

**Files:**
- Create: `app/app/Http/Controllers/Api/PaymentWebhookController.php`
- Modify: `app/routes/web.php` (добавить публичный роут без auth/tenant)
- Test: `app/tests/Feature/Billing/PaymentWebhookTest.php`

- [ ] **Step 1: Написать падающий feature-тест (успех + повтор + не-succeeded)**

`app/tests/Feature/Billing/PaymentWebhookTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Models\Tenant;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\Gateway\WebhookVerifyResult;

function seedPendingTx(Tenant $tenant, PaymentGateway $gw, string $payId): SaasTransaction
{
    return SaasTransaction::create([
        'tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => '500.00',
        'gateway_id' => $gw->id, 'gateway_code' => 'yookassa', 'gateway_payment_id' => $payId,
        'status' => 'pending', 'created_at' => now(),
    ]);
}

beforeEach(function () {
    $this->tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
    $this->gw = PaymentGateway::create([
        'code' => 'yookassa', 'name' => 'ЮKassa', 'driver' => 'yookassa',
        'config' => '', 'is_active' => true, 'accepts_methods' => ['card'], 'min_amount_rub' => '100.00',
    ]);
});

it('зачисляет баланс при succeeded и помечает tx success', function () {
    $tx = seedPendingTx($this->tenant, $this->gw, 'pay_ok');
    $this->mock(PaymentGatewayDriver::class, function ($m) {
        $m->shouldReceive('verifyPayment')->once()
            ->andReturn(new WebhookVerifyResult('pay_ok', 'succeeded', '500.00', 'bank_card'));
    });

    $resp = $this->postJson('/api/billing/payment-webhook', [
        'event' => 'payment.succeeded',
        'object' => ['id' => 'pay_ok'],
    ]);

    $resp->assertOk();
    expect($this->tenant->fresh()->balance_rub)->toBe('500.00')
        ->and($tx->fresh()->status)->toBe('success')
        ->and($tx->fresh()->balance_rub_after)->toBe('500.00');
});

it('идемпотентен — повторный webhook не зачисляет дважды', function () {
    seedPendingTx($this->tenant, $this->gw, 'pay_dup');
    $this->mock(PaymentGatewayDriver::class, function ($m) {
        $m->shouldReceive('verifyPayment')->twice()
            ->andReturn(new WebhookVerifyResult('pay_dup', 'succeeded', '500.00', 'bank_card'));
    });

    $payload = ['event' => 'payment.succeeded', 'object' => ['id' => 'pay_dup']];
    $this->postJson('/api/billing/payment-webhook', $payload)->assertOk();
    $this->postJson('/api/billing/payment-webhook', $payload)->assertOk();

    expect($this->tenant->fresh()->balance_rub)->toBe('500.00'); // не 1000
});

it('не зачисляет если статус не succeeded', function () {
    $tx = seedPendingTx($this->tenant, $this->gw, 'pay_pending');
    $this->mock(PaymentGatewayDriver::class, function ($m) {
        $m->shouldReceive('verifyPayment')->once()
            ->andReturn(new WebhookVerifyResult('pay_pending', 'pending', '500.00', null));
    });

    $this->postJson('/api/billing/payment-webhook', [
        'event' => 'payment.waiting_for_capture',
        'object' => ['id' => 'pay_pending'],
    ])->assertOk();

    expect($this->tenant->fresh()->balance_rub)->toBe('0.00')
        ->and($tx->fresh()->status)->toBe('pending');
});

it('возвращает 200 на неизвестный платёж не падая', function () {
    $this->mock(PaymentGatewayDriver::class, function ($m) {
        $m->shouldReceive('verifyPayment')->andReturn(new WebhookVerifyResult('x', 'succeeded', '1.00', null));
    });

    $this->postJson('/api/billing/payment-webhook', [
        'event' => 'payment.succeeded', 'object' => ['id' => 'unknown_pay'],
    ])->assertOk();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && ./vendor/bin/pest --filter=PaymentWebhookTest`
Expected: FAIL — route `/api/billing/payment-webhook` не существует (404).

- [ ] **Step 3: Создать контроллер**

`app/app/Http/Controllers/Api/PaymentWebhookController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Services\Billing\BillingTopupService;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

/**
 * Приём webhook от платёжного шлюза (ЮKassa). Публичный роут (без auth/tenant).
 *
 * Подлинность: НЕ доверяем телу webhook — по object.id делаем server-to-server
 * сверку через драйвер (GET /payments/{id}) и верим статусу из ответа шлюза.
 *
 * Идемпотентность: зачисляем только если saas_transactions.status='pending'.
 * Повторный webhook (поставщик ретранслирует) → 200 OK без двойного зачисления.
 * Условный UPDATE pending→success под FOR UPDATE сериализует гонку.
 *
 * Зачисление баланса делегируется BillingTopupService (lockForUpdate + ledger).
 * Webhook вне tenant-сессии → выставляем app.current_tenant_id из самой tx.
 */
class PaymentWebhookController extends Controller
{
    public function __construct(
        private readonly PaymentGatewayDriver $driver,
        private readonly BillingTopupService $topupService,
    ) {}

    public function receive(Request $request): JsonResponse
    {
        $paymentId = (string) $request->input('object.id', '');
        if ($paymentId === '') {
            return response()->json(['status' => 'ignored'], 200);
        }

        $tx = SaasTransaction::where('gateway_payment_id', $paymentId)->first();
        if ($tx === null) {
            return response()->json(['status' => 'unknown'], 200);
        }

        // Server-to-server сверка — источник правды о статусе.
        $verify = $this->driver->verifyPayment($this->gatewayFor($tx), $paymentId);
        if (! $verify->isSucceeded()) {
            return response()->json(['status' => 'not_paid'], 200);
        }

        DB::transaction(function () use ($tx, $verify) {
            // Атомарно занимаем pending→success; вернёт 0 если уже не pending (дубль/гонка).
            $claimed = SaasTransaction::where('id', $tx->id)
                ->where('status', SaasTransaction::STATUS_PENDING)
                ->update(['status' => SaasTransaction::STATUS_SUCCESS, 'completed_at' => now()]);

            if ($claimed === 0) {
                return; // уже зачислено — идемпотентный no-op
            }

            // Webhook вне tenant-сессии → выставляем GUC для RLS этой транзакции.
            DB::statement("SET LOCAL app.current_tenant_id = '".(int) $tx->tenant_id."'");

            $balanceTx = $this->topupService->topup(
                (int) $tx->tenant_id, (string) $tx->amount_rub, null
            );

            SaasTransaction::where('id', $tx->id)->update([
                'balance_rub_after' => $balanceTx->balance_rub_after,
                'payment_method' => $verify->paymentMethod,
            ]);
        });

        return response()->json(['status' => 'ok'], 200);
    }

    private function gatewayFor(SaasTransaction $tx): PaymentGateway
    {
        return $tx->gateway_id !== null
            ? PaymentGateway::findOrFail($tx->gateway_id)
            : PaymentGateway::where('code', $tx->gateway_code)->firstOrFail();
    }
}
```

> Зависимость `PaymentGatewayDriver` приходит из биндинга Task 6 Step 3b. В тестах он замокан.

- [ ] **Step 4: Добавить публичный роут**

В `app/routes/web.php` — ВНЕ группы `['auth:sanctum','tenant']` (webhook анонимный), рядом с другими публичными webhook-роутами:

```php
Route::post('/api/billing/payment-webhook', 'App\Http\Controllers\Api\PaymentWebhookController@receive')
    ->withoutMiddleware(['web']); // CSRF не применим к серверному webhook
```

> Если в проекте webhook-роуты регистрируются в `routes/api.php` — поместить туда по образцу `SupplierWebhookController`. Проверить, как зарегистрирован `/api/webhook/supplier/{secret}`, и следовать тому же файлу/группе.

- [ ] **Step 5: Запустить — убедиться, что проходит**

Run: `cd app && ./vendor/bin/pest --filter=PaymentWebhookTest`
Expected: PASS (4 passed).

- [ ] **Step 6: Коммит**

```bash
git add app/app/Http/Controllers/Api/PaymentWebhookController.php app/routes/web.php app/tests/Feature/Billing/PaymentWebhookTest.php
git commit -m "feat(billing): PaymentWebhookController — идемпотентное зачисление по server-to-server сверке"
```

---

### Task 8: Админ — ввод ключей шлюза (зашифровано)

**Files:**
- Create: `app/app/Http/Controllers/Api/AdminPaymentGatewayController.php`
- Modify: `app/routes/web.php` (админ-группа)
- Test: `app/tests/Feature/Billing/AdminPaymentGatewayTest.php`

- [ ] **Step 1: Написать падающий тест**

`app/tests/Feature/Billing/AdminPaymentGatewayTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\PaymentGateway;
use Illuminate\Support\Facades\Crypt;

it('сохраняет ключи шлюза в зашифрованном виде', function () {
    $admin = adminUser(); // существующий helper для SaaS-admin; если иной — см. AdminBillingActionsTest

    $resp = $this->actingAsAdmin($admin)->putJson('/api/admin/payment-gateways/yookassa', [
        'shop_id' => 'shop_42',
        'secret_key' => 'live_secret_value',
        'is_active' => true,
    ]);

    $resp->assertOk();
    $gw = PaymentGateway::where('code', 'yookassa')->firstOrFail();
    expect($gw->is_active)->toBeTrue()
        ->and($gw->config)->not->toContain('live_secret_value') // зашифровано
        ->and(Crypt::decrypt($gw->config))->toBe(['shop_id' => 'shop_42', 'secret_key' => 'live_secret_value']);
});
```

> Перед реализацией: открыть `app/tests/Feature/AdminBillingActionsTest.php` и взять оттуда реальный способ аутентификации SaaS-admin (`actingAsAdmin`/helper). Привести имена в тесте в соответствие.

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && ./vendor/bin/pest --filter=AdminPaymentGatewayTest`
Expected: FAIL — route не существует.

- [ ] **Step 3: Создать контроллер**

`app/app/Http/Controllers/Api/AdminPaymentGatewayController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\PaymentGateway;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;

/**
 * SaaS-admin: ввод секретных ключей платёжного шлюза.
 * config хранится Crypt::encrypt — ключи не попадают в БД в открытом виде,
 * не возвращаются обратно клиенту.
 */
class AdminPaymentGatewayController extends Controller
{
    public function update(Request $request, string $code): JsonResponse
    {
        $validated = $request->validate([
            'shop_id' => ['required', 'string', 'max:255'],
            'secret_key' => ['required', 'string', 'max:255'],
            'is_active' => ['required', 'boolean'],
        ]);

        $gw = PaymentGateway::firstOrNew(['code' => $code]);
        $gw->name = $gw->name ?? 'ЮKassa';
        $gw->driver = $gw->driver ?? $code;
        $gw->config = Crypt::encrypt([
            'shop_id' => $validated['shop_id'],
            'secret_key' => $validated['secret_key'],
        ]);
        $gw->is_active = $validated['is_active'];
        if ($gw->min_amount_rub === null) {
            $gw->min_amount_rub = '100.00';
        }
        $gw->save();

        return response()->json(['status' => 'ok', 'code' => $gw->code, 'is_active' => $gw->is_active]);
    }
}
```

- [ ] **Step 4: Добавить роут в админ-группу**

В `app/routes/web.php` в существующую группу админ-роутов (рядом с `/api/admin/system-settings`):

```php
    Route::put('/api/admin/payment-gateways/{code}', 'App\Http\Controllers\Api\AdminPaymentGatewayController@update')
        ->where('code', '[a-z0-9_]+');
```

> Поместить под те же middleware, что и другие `/api/admin/*` роуты (проверить группу `system-settings` строки ~149 web.php) — auth SaaS-admin.

- [ ] **Step 5: Запустить — убедиться, что проходит**

Run: `cd app && ./vendor/bin/pest --filter=AdminPaymentGatewayTest`
Expected: PASS (1 passed).

- [ ] **Step 6: Коммит**

```bash
git add app/app/Http/Controllers/Api/AdminPaymentGatewayController.php app/routes/web.php app/tests/Feature/Billing/AdminPaymentGatewayTest.php
git commit -m "feat(billing): админ-эндпоинт ввода ключей ЮKassa с шифрованием config"
```

---

### Task 9: Фронт — пресеты сумм, редирект, обработка возврата

**Files:**
- Modify: `app/resources/js/api/billing.ts:104-108` (тип ответа topup)
- Modify: `app/resources/js/components/.../TopupDialog.vue` (найти точный путь — `grep -rl TopupDialog app/resources/js`)
- Modify: `app/resources/js/views/BillingView.vue`
- Test: `app/tests/Frontend/TopupDialog.spec.ts` (существует — расширить)

- [ ] **Step 1: Обновить тип ответа topup в billing.ts**

Заменить `TopupResult` и `topup()` (строки 91-108) так, чтобы ответ допускал две формы:

```typescript
/** Результат POST /api/billing/topup — либо мгновенная заглушка, либо редирект на шлюз. */
export interface TopupResult {
    // Ветка заглушки (флаг ВЫКЛ):
    transaction?: {
        id: number;
        type: string;
        amount_rub: string;
        balance_rub_after: string | null;
        created_at: string;
    };
    balance_rub?: string;
    // Ветка шлюза (флаг ВКЛ):
    confirmation_url?: string;
}

/** POST /api/billing/topup — пополнить рублёвый баланс (заглушка ИЛИ редирект на оплату). */
export async function topup(amountRub: number): Promise<TopupResult> {
    await ensureCsrfCookie();
    const { data } = await apiClient.post<TopupResult>('/api/billing/topup', { amount_rub: amountRub });
    return data;
}
```

- [ ] **Step 2: Написать/расширить падающий Vitest**

В `app/tests/Frontend/TopupDialog.spec.ts` добавить два теста:

```typescript
it('при ответе с confirmation_url делает редирект на оплату', async () => {
    // arrange: мок topup → { confirmation_url }
    // act: ввести сумму, нажать «Пополнить»
    // assert: window.location.href === confirmation_url (через инъекцию редирект-функции)
    // (конкретные селекторы — по существующему паттерну в этом spec-файле)
});

it('пресет-кнопки 1000/3000/5000 подставляют сумму', async () => {
    // assert: клик по пресету выставляет v-model суммы
});
```

> Перед написанием — открыть существующий `TopupDialog.spec.ts` и повторить его стиль (factory/wrapper/vuetify), как в `BillingView.spec.ts`. Редирект сделать через внедряемую функцию (проп/сервис), а не прямой `window.location`, чтобы тестировалось без реальной навигации.

- [ ] **Step 3: Запустить — убедиться, что падает**

Run: `cd app && npm run test:vue -- TopupDialog`
Expected: FAIL — новых пресетов/редиректа нет.

- [ ] **Step 4: Реализовать в TopupDialog.vue**

- Добавить ряд кнопок-пресетов (1000 / 3000 / 5000 ₽), выставляющих сумму.
- В обработчике submit: если ответ содержит `confirmation_url` → выполнить редирект (через внедряемую функцию `redirect(url)`, по умолчанию `window.location.assign`); иначе — текущее поведение (закрыть диалог, обновить кошелёк, показать успех).

- [ ] **Step 5: Обработка возврата в BillingView.vue**

При наличии query-параметра `?topup=return` показать инфо-баннер «Платёж обрабатывается, баланс обновится автоматически в течение минуты» и сделать refresh кошелька (`getWallet()` + `getBalanceStatus()`).

- [ ] **Step 6: Запустить — убедиться, что проходит**

Run: `cd app && npm run test:vue -- TopupDialog`
Expected: PASS.

- [ ] **Step 7: Коммит**

```bash
git add app/resources/js/api/billing.ts app/resources/js/components app/resources/js/views/BillingView.vue app/tests/Frontend/TopupDialog.spec.ts
git commit -m "feat(billing): фронт — пресеты сумм, редирект на оплату, обработка возврата"
```

---

### Task 10: Ручная проверка на песочнице + регрессия

**Files:** нет (проверочный таск).

- [ ] **Step 1: Завести тестовый магазин ЮKassa**

Зарегистрировать тестовый магазин ЮKassa (без ООО/договора) → получить test `shop_id` + `secret_key`. Внести через админ-форму (Task 8) на dev-окружении, включить `billing_yookassa_enabled=true`.

- [ ] **Step 2: Полный цикл тестовой картой**

В UI: «Пополнить» → редирект на ЮKassa → оплатить тестовой картой (`5555 5555 5555 4444`, любой будущий срок/CVC) → вернуться. Настроить webhook ЮKassa на `https://<dev-url>/api/billing/payment-webhook`. Убедиться: `saas_transactions` → `success`, баланс пополнен, строка в `balance_transactions(topup)`.

- [ ] **Step 3: Проверить идемпотентность вручную**

Повторно отправить тот же webhook (из ЛК ЮKassa «повторить» или curl с тем же `object.id`) → баланс НЕ меняется второй раз.

- [ ] **Step 4: Вернуть флаг в выкл и прогнать регрессию**

```bash
# вернуть billing_yookassa_enabled=false (прод-дефолт)
cd app && composer test
```
Expected: вся регрессия GREEN; заглушка работает как прежде.

- [ ] **Step 5: Финальный self-review (§8 CLAUDE.md)**

- `db/schema.sql` ↔ `db/CHANGELOG_schema.md` синхронны (только seed-строки добавлены).
- Larastan: `cd app && composer stan` — 0 новых ошибок.
- Pint: `cd app && composer pint`.
- Нет хвостов/временных файлов.

- [ ] **Step 6: Коммит финальных правок (если были от pint/stan)**

```bash
git add -A
git commit -m "chore(billing): финальная вычистка — pint/stan по онлайн-пополнению"
```

---

## Self-Review плана (выполнено автором)

**1. Покрытие спеки:**
- §4.1 поток онлайн → Task 5,6,7. ✓
- §4.2 поток заглушки → Task 6 (ветка флага ВЫКЛ). ✓
- §4.3 компоненты (driver/manager/service/webhook) → Task 3,4,5,7. ✓
- §4.4 данные (модели существующих таблиц) → Task 1; флаги → Task 2. ✓
- §4.5 безопасность (шифрование ключей → Task 1,8; server-to-server сверка → Task 4,7; идемпотентность → Task 7; RLS GUC → Task 7). ✓
- §4.6 чек 54-ФЗ → заложен (флаг `billing_receipt_enabled` Task 2; `receipt` param в драйвере Task 4; передаётся через `OnlineTopupService` — **примечание:** в Task 5 receipt пока `null`; включение чека = отдельный мелкий шаг при ОФД, в scope «заложено», что выполнено сигнатурой драйвера). ✓
- §4.7 флаги в админке → Task 2 (seed) + Task 8 (ключи) + существующий экран settings (переключатель). ✓
- §5 UI → Task 9. ✓
- §6 тесты → каждый Task TDD + Task 10 ручная. ✓

**2. Placeholder-скан:** код приведён в каждом code-step; в Task 9 фронт-шаги ссылаются на существующий spec-стиль (осознанно, т.к. точные селекторы зависят от текущего `TopupDialog.spec.ts` — указано открыть файл). Путь `TopupDialog.vue` — найти через grep (указано). Это не placeholder логики, а привязка к существующему коду.

**3. Согласованность типов:** `PaymentGatewayDriver::createPayment/verifyPayment`, DTO `CreatePaymentResult.{gatewayPaymentId,confirmationUrl}`, `WebhookVerifyResult.{status,amountRub,paymentMethod,isSucceeded()}`, статусы `pending/success` — согласованы между Task 3,4,5,7. `OnlineTopupService::start(...)` сигнатура одинакова в Task 5 и Task 6. ✓

---

## Execution Handoff

После сохранения плана — выбор способа исполнения (под стеной «роутер-наставник», коммиты через owner-escape).
