# ONOXSOFT License Server — L1 (MVP) 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:** Yeni Laravel projesi `C:\xampp\htdocs\ONOXSOFT-LICENSE\` üzerinde, müşterinin onox.com.tr'den lisans satın alıp panelin otomatik aktive olmasını sağlayan tam bir MVP — public site, Iyzico ödeme, Ed25519-imzalı JWT veren verification API, admin paneli ve müşteri portali dahil.

**Architecture:** Laravel 11 + Inertia + Vue 3 + Tailwind monolit. DDD-light bounded contexts (Customer/License/Order/Verify/Security/Billing). MariaDB 10.11 + Redis 7. Pest 3 ile TDD. Ed25519 imzalı JWT (`sodium_crypto_sign_*`). Iyzico Sandbox 3D-Secure. Spec referansı: `docs/superpowers/specs/2026-05-13-onoxsoft-license-server-design.md`.

**Tech Stack:** Laravel 11.31+, Inertia 2.0, Vue 3.4+, Tailwind 3.4+, Pest 3.5+, Laravel Fortify, Spatie Permission, Horizon, MariaDB 10.11, Redis, libsodium (PHP builtin), Iyzico PHP SDK (`iyzico/iyzipay-php`).

**Estimated:** 8 hafta solo part-time (haftada 10-15 saat).

---

## File Structure (üst-düzey)

Yeni proje root: `C:\xampp\htdocs\ONOXSOFT-LICENSE\`

```
app/
├── Domain/
│   ├── Customer/{Models,Services,Actions,Events}
│   ├── License/{Models,Services,Actions,Events,Enums}
│   ├── Order/{Models,Services,Actions,Events}
│   ├── Verify/{Services,Actions}
│   ├── Security/{Models,Services}
│   └── Billing/{Services}            # L5'te dolacak
├── Http/
│   ├── Controllers/
│   │   ├── PublicController.php
│   │   ├── BuyController.php
│   │   ├── Auth/...
│   │   ├── Portal/...
│   │   ├── Admin/...
│   │   └── Api/V1/...
│   └── Middleware/
│       ├── VerifyApiSignature.php
│       └── EnsureCustomerKvkkConsent.php
├── Jobs/
└── Console/Commands/
resources/js/
├── Pages/
│   ├── Public/{Home,Pricing,Features}.vue
│   ├── Buy/{Index,Success,Failure}.vue
│   ├── Auth/{Login,Register,Forgot,Reset,TwoFactor}.vue
│   ├── Portal/{Dashboard,Licenses,LicenseDetail,Invoices,Settings}.vue
│   └── Admin/{Dashboard,Customers,Licenses,Orders,CrackReports}.vue
├── Layouts/{PublicLayout,PortalLayout,AdminLayout}.vue
└── Components/{...}
config/
└── onoxsoft.php                      # license keypair, iyzico, intent_secret
database/migrations/                  # 8 ana tablo
tests/Feature/                        # Pest 3 testler
tests/Unit/
routes/{web,api}.php
```

---

## Phase 1 — Foundation (Tasks 1-4)

### Task 1: Project Bootstrap

**Files:**
- Create: `C:\xampp\htdocs\ONOXSOFT-LICENSE\` (proje root)
- Create: `.env`, `composer.json`, `package.json` (laravel default)
- Modify: `config/app.php` (name)

- [ ] **Step 1: Yeni Laravel projesi oluştur**

Run:
```bash
cd C:/xampp/htdocs && composer create-project laravel/laravel:^11.0 ONOXSOFT-LICENSE
cd ONOXSOFT-LICENSE
```

Expected: Yeni Laravel skeleton, `php artisan --version` → Laravel Framework 11.x.

- [ ] **Step 2: Inertia + Vue + Tailwind stack kur**

Run:
```bash
composer require inertiajs/inertia-laravel tightenco/ziggy
npm install -D @vitejs/plugin-vue vue@^3.4 @inertiajs/vue3 tailwindcss@^3.4 postcss autoprefixer @tailwindcss/forms
npx tailwindcss init -p
php artisan inertia:middleware
```

Expected: `tailwind.config.js`, `postcss.config.js`, `app/Http/Middleware/HandleInertiaRequests.php` oluşur.

- [ ] **Step 3: Test/auth/perm/queue paketlerini kur**

Run:
```bash
composer require laravel/fortify laravel/horizon spatie/laravel-permission predis/predis pragmarx/google2fa
composer require --dev pestphp/pest pestphp/pest-plugin-laravel mockery/mockery laravel/pint
php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"
php artisan vendor:publish --provider="Laravel\Horizon\HorizonServiceProvider"
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
./vendor/bin/pest --init
```

Expected: `config/fortify.php`, `config/horizon.php`, `config/permission.php`, `tests/Pest.php` oluşur.

- [ ] **Step 4: Iyzico SDK + sodium kontrolü**

Run:
```bash
composer require iyzico/iyzipay-php
php -r "echo SODIUM_LIBRARY_VERSION;"
```

Expected: sodium versiyonu çıkar (1.0.18+). PHP 8.2+'da builtin.

- [ ] **Step 5: `.env` ayarları**

Modify `.env`:
```env
APP_NAME="Onoxsoft License Server"
APP_URL=http://onox-license.test
APP_TIMEZONE=Europe/Istanbul
APP_LOCALE=tr

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=onoxsoft_license
DB_USERNAME=root
DB_PASSWORD=

REDIS_HOST=127.0.0.1
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
CACHE_STORE=redis

# Onoxsoft license — Ed25519 keypair (Task 11'de doldurulacak)
ONOXSOFT_LICENSE_PRIVATE_KEY=
ONOXSOFT_LICENSE_PUBLIC_KEY=

# Intent token (panel ↔ server HMAC)
ONOXSOFT_INTENT_SECRET=

# Iyzico sandbox
IYZICO_API_KEY=sandbox-XXXX
IYZICO_SECRET=sandbox-XXXX
IYZICO_BASE_URL=https://sandbox-api.iyzipay.com
```

- [ ] **Step 6: `config/onoxsoft.php` oluştur**

Create `config/onoxsoft.php`:
```php
<?php

return [
    'license' => [
        'private_key' => env('ONOXSOFT_LICENSE_PRIVATE_KEY'),
        'public_key'  => env('ONOXSOFT_LICENSE_PUBLIC_KEY'),
        'jwt_ttl_hours' => 25,
        'heartbeat_interval_hours' => 6,
        'grace_period_hours' => 72,
    ],
    'intent' => [
        'secret' => env('ONOXSOFT_INTENT_SECRET'),
        'ttl_seconds' => 3600,
    ],
    'iyzico' => [
        'api_key' => env('IYZICO_API_KEY'),
        'secret'  => env('IYZICO_SECRET'),
        'base_url'=> env('IYZICO_BASE_URL'),
    ],
    'tiers' => [
        'trial'      => ['account_limit' => 5,   'features' => ['api'],                                        'price' => 0],
        'lite'       => ['account_limit' => 30,  'features' => ['api','autossl','plugins'],                    'price' => 31500],
        'pro'        => ['account_limit' => 100, 'features' => ['api','autossl','plugins','mail_server','whmcs_plugin','ai_assistant','cpanel_migration'], 'price' => 67500],
        'unlimited'  => ['account_limit' => 0,   'features' => ['*'],                                          'price' => 112500],
        'enterprise' => ['account_limit' => 0,   'features' => ['*','cluster','reseller'],                     'price' => 355500],
    ],
];
```

(Fiyatlar kuruş cinsinden — ₺315 = 31500 kuruş)

- [ ] **Step 7: Git init + ilk commit**

Run:
```bash
cd C:/xampp/htdocs/ONOXSOFT-LICENSE
git init
git add -A
git commit -m "chore: project bootstrap — Laravel 11 + Inertia + Vue 3 + Tailwind + Pest"
```

Expected: Git repo initialized, ~80-100 files committed.

---

### Task 2: Database & Migrations — All 8 Core Tables

**Files:**
- Create: `database/migrations/2026_05_13_120001_create_customers_table.php`
- Create: `database/migrations/2026_05_13_120002_create_licenses_table.php`
- Create: `database/migrations/2026_05_13_120003_create_license_bindings_table.php`
- Create: `database/migrations/2026_05_13_120004_create_license_audit_table.php`
- Create: `database/migrations/2026_05_13_120005_create_orders_table.php`
- Create: `database/migrations/2026_05_13_120006_create_invoices_table.php`
- Create: `database/migrations/2026_05_13_120007_create_crack_reports_table.php`
- Create: `database/migrations/2026_05_13_120008_create_purchase_intents_table.php`

- [ ] **Step 1: customers migration**

Create `database/migrations/2026_05_13_120001_create_customers_table.php`:
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void {
        Schema::create('customers', function (Blueprint $t) {
            $t->id();
            $t->string('email')->unique();
            $t->timestamp('email_verified_at')->nullable();
            $t->string('password');
            $t->string('name');
            $t->string('company')->nullable();
            $t->string('tax_number', 20)->nullable();      // VKN/TCKN
            $t->string('phone', 20)->nullable();
            $t->json('billing_address')->nullable();
            $t->timestamp('kvkk_consented_at')->nullable();
            $t->text('two_factor_secret')->nullable();
            $t->text('two_factor_recovery_codes')->nullable();
            $t->rememberToken();
            $t->timestamps();
        });
    }
    public function down(): void { Schema::dropIfExists('customers'); }
};
```

- [ ] **Step 2: licenses migration**

Create `database/migrations/2026_05_13_120002_create_licenses_table.php`:
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void {
        Schema::create('licenses', function (Blueprint $t) {
            $t->id();
            $t->uuid('uuid')->unique();
            $t->foreignId('customer_id')->constrained()->cascadeOnDelete();
            $t->enum('tier', ['trial','lite','pro','unlimited','enterprise']);
            $t->enum('status', ['active','suspended','expired','revoked'])->default('active');
            $t->unsignedInteger('account_limit')->default(0);
            $t->json('features');
            $t->timestamp('purchased_at');
            $t->timestamp('expires_at');
            $t->timestamp('last_heartbeat_at')->nullable();
            $t->timestamp('revoked_at')->nullable();
            $t->text('revoked_reason')->nullable();
            $t->timestamps();
            $t->index(['status', 'expires_at']);
        });
    }
    public function down(): void { Schema::dropIfExists('licenses'); }
};
```

- [ ] **Step 3: license_bindings migration**

Create `database/migrations/2026_05_13_120003_create_license_bindings_table.php`:
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void {
        Schema::create('license_bindings', function (Blueprint $t) {
            $t->id();
            $t->foreignId('license_id')->constrained()->cascadeOnDelete();
            $t->string('bound_ip', 45);
            $t->string('bound_machine_id', 64);
            $t->string('bound_hostname', 253)->nullable();
            $t->string('panel_version', 20)->nullable();
            $t->timestamp('first_seen_at');
            $t->timestamp('last_seen_at');
            $t->enum('status', ['active','stale','revoked'])->default('active');
            $t->timestamps();
            $t->index(['license_id', 'bound_ip', 'bound_machine_id', 'status'], 'idx_bindings_lookup');
        });
    }
    public function down(): void { Schema::dropIfExists('license_bindings'); }
};
```

- [ ] **Step 4: license_audit, orders, invoices, crack_reports, purchase_intents**

Aynı pattern ile 5 migration daha (spec §7'deki şemalardan birebir). Her dosya kendi tarihiyle `120004`-`120008` sıralı.

`license_audit` (event log):
```php
Schema::create('license_audit', function (Blueprint $t) {
    $t->id();
    $t->foreignId('license_id')->constrained()->cascadeOnDelete();
    $t->enum('event', ['activate','heartbeat','ip_transfer','suspend','revoke','crack_detected','expired']);
    $t->json('payload')->nullable();
    $t->string('ip', 45)->nullable();
    $t->string('user_agent', 500)->nullable();
    $t->timestamp('created_at');
    $t->index(['license_id', 'created_at']);
});
```

`orders`:
```php
Schema::create('orders', function (Blueprint $t) {
    $t->id();
    $t->foreignId('customer_id')->constrained()->cascadeOnDelete();
    $t->foreignId('license_id')->nullable()->constrained()->nullOnDelete();
    $t->unsignedBigInteger('amount_cents');
    $t->char('currency', 3)->default('TRY');
    $t->enum('status', ['pending','paid','failed','refunded'])->default('pending');
    $t->string('iyzico_payment_id', 64)->nullable();
    $t->string('iyzico_basket_id', 64)->nullable();
    $t->string('intent_token', 255)->nullable();
    $t->json('payload')->nullable();      // tier seçimi, fatura adresi snapshot
    $t->timestamps();
    $t->timestamp('paid_at')->nullable();
    $t->index('iyzico_basket_id');
});
```

`invoices`:
```php
Schema::create('invoices', function (Blueprint $t) {
    $t->id();
    $t->foreignId('order_id')->constrained()->cascadeOnDelete();
    $t->string('number', 32)->unique();
    $t->string('parasut_id', 64)->nullable();
    $t->string('pdf_path', 500)->nullable();
    $t->timestamp('sent_at')->nullable();
    $t->unsignedBigInteger('total_cents');
    $t->unsignedBigInteger('tax_cents');
    $t->timestamps();
});
```

`crack_reports`:
```php
Schema::create('crack_reports', function (Blueprint $t) {
    $t->id();
    $t->foreignId('license_id')->nullable()->constrained()->nullOnDelete();
    $t->enum('signal', ['tamper','tls_pin_fail','time_warp','honeypot_hit','dual_install','integrity_fail']);
    $t->enum('severity', ['low','medium','high','critical']);
    $t->json('payload');
    $t->string('reported_ip', 45)->nullable();
    $t->timestamp('reviewed_at')->nullable();
    $t->foreignId('reviewer_id')->nullable()->constrained('customers');
    $t->string('action_taken', 255)->nullable();
    $t->timestamps();
});
```

`purchase_intents`:
```php
Schema::create('purchase_intents', function (Blueprint $t) {
    $t->id();
    $t->string('token', 255)->unique();
    $t->string('panel_ip', 45);
    $t->string('panel_machine_id', 64);
    $t->string('panel_hostname', 253)->nullable();
    $t->string('panel_version', 20)->nullable();
    $t->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
    $t->foreignId('order_id')->nullable()->constrained()->nullOnDelete();
    $t->timestamp('expires_at');
    $t->timestamp('consumed_at')->nullable();
    $t->timestamps();
});
```

- [ ] **Step 5: DB yarat + migrate**

Run:
```bash
mysql -uroot -e "CREATE DATABASE onoxsoft_license CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
php artisan migrate
```

Expected: 8 tablo + Laravel default tablolar (cache, jobs, sessions). `php artisan migrate:status` ile doğrula.

- [ ] **Step 6: Commit**

```bash
git add database/migrations/
git commit -m "feat(db): 8 core tabloları — customers, licenses, bindings, audit, orders, invoices, crack_reports, purchase_intents"
```

---

### Task 3: Customer Model + Authentication (Fortify)

**Files:**
- Create: `app/Domain/Customer/Models/Customer.php`
- Create: `database/factories/CustomerFactory.php`
- Modify: `config/auth.php`
- Modify: `app/Providers/FortifyServiceProvider.php`
- Create: `tests/Feature/Auth/RegistrationTest.php`
- Create: `tests/Feature/Auth/LoginTest.php`

- [ ] **Step 1: Customer modelini yaz**

Create `app/Domain/Customer/Models/Customer.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\Customer\Models;

use Database\Factories\CustomerFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;

class Customer extends Authenticatable {
    use HasFactory, Notifiable, TwoFactorAuthenticatable;

    protected $fillable = [
        'email','password','name','company','tax_number','phone',
        'billing_address','kvkk_consented_at',
    ];

    protected $hidden = ['password','remember_token','two_factor_recovery_codes','two_factor_secret'];

    protected function casts(): array {
        return [
            'email_verified_at' => 'datetime',
            'kvkk_consented_at' => 'datetime',
            'billing_address'   => 'array',
            'password'          => 'hashed',
        ];
    }

    protected static function newFactory(): CustomerFactory {
        return CustomerFactory::new();
    }
}
```

- [ ] **Step 2: CustomerFactory yaz**

Create `database/factories/CustomerFactory.php`:
```php
<?php
namespace Database\Factories;
use App\Domain\Customer\Models\Customer;
use Illuminate\Database\Eloquent\Factories\Factory;

class CustomerFactory extends Factory {
    protected $model = Customer::class;
    public function definition(): array {
        return [
            'email'              => fake()->unique()->safeEmail(),
            'password'           => bcrypt('password'),
            'name'               => fake()->name(),
            'company'            => fake()->company(),
            'tax_number'         => fake()->numerify('##########'),
            'phone'              => fake()->phoneNumber(),
            'email_verified_at'  => now(),
            'kvkk_consented_at'  => now(),
        ];
    }
}
```

- [ ] **Step 3: config/auth.php — provider model'i Customer'a çevir**

Modify `config/auth.php` — `providers.users.model`:
```php
'users' => [
    'driver' => 'eloquent',
    'model'  => App\Domain\Customer\Models\Customer::class,
],
```

Ayrıca `passwords.users.table` → `'customers'` yap.

- [ ] **Step 4: Fortify ayarları**

Modify `config/fortify.php`:
- `'features'` → `[Features::registration(), Features::resetPasswords(), Features::emailVerification(), Features::updatePasswords(), Features::twoFactorAuthentication(['confirm' => true])]`
- `'guard' => 'web'`, `'username' => 'email'`, `'home' => '/portal'`

`app/Providers/FortifyServiceProvider.php` — `boot()` içinde:
```php
Fortify::loginView(fn() => Inertia::render('Auth/Login'));
Fortify::registerView(fn() => Inertia::render('Auth/Register'));
Fortify::requestPasswordResetLinkView(fn() => Inertia::render('Auth/Forgot'));
Fortify::resetPasswordView(fn($request) => Inertia::render('Auth/Reset', ['token' => $request->token, 'email' => $request->email]));
Fortify::twoFactorChallengeView(fn() => Inertia::render('Auth/TwoFactor'));
```

`CreateNewUser` action'ını `app/Actions/Fortify/CreateNewUser.php`'de Customer'a göre güncelle (name, email, password validasyonu + KVKK consent flag).

- [ ] **Step 5: Pest setup + helpers**

Modify `tests/Pest.php`:
```php
<?php
uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class)->in('Feature');
uses(Tests\TestCase::class)->in('Unit');

function actingAsCustomer(array $attrs = []): App\Domain\Customer\Models\Customer {
    $c = App\Domain\Customer\Models\Customer::factory()->create($attrs);
    test()->actingAs($c);
    return $c;
}
```

- [ ] **Step 6: Failing test — registration**

Create `tests/Feature/Auth/RegistrationTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;

test('müşteri kayıt olabilir', function () {
    $response = $this->postJson('/register', [
        'name' => 'Test Hosting',
        'email' => 'test@host.com',
        'password' => 'Sifre1234!',
        'password_confirmation' => 'Sifre1234!',
        'kvkk_consent' => true,
    ]);

    $response->assertStatus(302); // Fortify default redirect

    expect(Customer::where('email', 'test@host.com')->exists())->toBeTrue();
    $c = Customer::where('email', 'test@host.com')->first();
    expect($c->kvkk_consented_at)->not->toBeNull();
});

test('KVKK onayı olmadan kayıt reddedilir', function () {
    $this->postJson('/register', [
        'name' => 'X', 'email' => 'x@y.com',
        'password' => 'Sifre1234!', 'password_confirmation' => 'Sifre1234!',
        // kvkk_consent eksik
    ])->assertStatus(422)->assertJsonValidationErrors(['kvkk_consent']);
});
```

- [ ] **Step 7: Run test (fail bekleniyor)**

Run: `./vendor/bin/pest tests/Feature/Auth/RegistrationTest.php`
Expected: FAIL — Fortify CreateNewUser KVKK validasyonu yok.

- [ ] **Step 8: CreateNewUser'ı güncelle**

Modify `app/Actions/Fortify/CreateNewUser.php`:
```php
public function create(array $input): Customer {
    Validator::make($input, [
        'name'  => ['required', 'string', 'max:255'],
        'email' => ['required', 'string', 'email', 'max:255', 'unique:customers'],
        'password' => $this->passwordRules(),
        'kvkk_consent' => ['required', 'accepted'],
    ])->validate();

    return Customer::create([
        'name'              => $input['name'],
        'email'             => $input['email'],
        'password'          => Hash::make($input['password']),
        'kvkk_consented_at' => now(),
    ]);
}
```

- [ ] **Step 9: Run tests (pass bekleniyor)**

Run: `./vendor/bin/pest tests/Feature/Auth/`
Expected: 2 passed.

- [ ] **Step 10: Commit**

```bash
git add app/Domain/Customer/ app/Actions/Fortify/ app/Providers/FortifyServiceProvider.php config/auth.php config/fortify.php database/factories/CustomerFactory.php tests/
git commit -m "feat(auth): Customer modeli + Fortify register/login + KVKK validation"
```

---

### Task 4: License + Binding + Audit Models

**Files:**
- Create: `app/Domain/License/Enums/{LicenseTier,LicenseStatus,BindingStatus}.php`
- Create: `app/Domain/License/Models/{License,LicenseBinding,LicenseAudit}.php`
- Create: `database/factories/{LicenseFactory,LicenseBindingFactory}.php`
- Create: `tests/Unit/License/LicenseModelTest.php`

- [ ] **Step 1: Enum'lar**

Create `app/Domain/License/Enums/LicenseTier.php`:
```php
<?php
namespace App\Domain\License\Enums;
enum LicenseTier: string {
    case Trial      = 'trial';
    case Lite       = 'lite';
    case Pro        = 'pro';
    case Unlimited  = 'unlimited';
    case Enterprise = 'enterprise';

    public function accountLimit(): int {
        return config("onoxsoft.tiers.{$this->value}.account_limit", 0);
    }
    public function features(): array {
        return config("onoxsoft.tiers.{$this->value}.features", []);
    }
    public function priceCents(): int {
        return config("onoxsoft.tiers.{$this->value}.price", 0);
    }
    public function label(): string {
        return ['trial'=>'Trial','lite'=>'Lite','pro'=>'Pro','unlimited'=>'Unlimited','enterprise'=>'Enterprise'][$this->value];
    }
}
```

Aynı şekilde `LicenseStatus` (active/suspended/expired/revoked) ve `BindingStatus` (active/stale/revoked) enum'ları.

- [ ] **Step 2: License modeli**

Create `app/Domain/License/Models/License.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Models;

use App\Domain\Customer\Models\Customer;
use App\Domain\License\Enums\{LicenseStatus, LicenseTier};
use Database\Factories\LicenseFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\{BelongsTo, HasMany};

class License extends Model {
    use HasFactory;

    protected $fillable = [
        'uuid','customer_id','tier','status','account_limit','features',
        'purchased_at','expires_at','last_heartbeat_at','revoked_at','revoked_reason',
    ];

    protected function casts(): array {
        return [
            'tier'              => LicenseTier::class,
            'status'            => LicenseStatus::class,
            'features'          => 'array',
            'purchased_at'      => 'datetime',
            'expires_at'        => 'datetime',
            'last_heartbeat_at' => 'datetime',
            'revoked_at'        => 'datetime',
        ];
    }

    protected static function booted(): void {
        static::creating(function (License $l) {
            if (empty($l->uuid)) $l->uuid = (string) \Str::uuid();
        });
    }

    public function customer(): BelongsTo { return $this->belongsTo(Customer::class); }
    public function bindings(): HasMany { return $this->hasMany(LicenseBinding::class); }
    public function audits(): HasMany { return $this->hasMany(LicenseAudit::class); }

    public function activeBinding(): ?LicenseBinding {
        return $this->bindings()->where('status','active')->latest('last_seen_at')->first();
    }

    public function isExpired(): bool { return $this->expires_at->isPast(); }
    public function isActive(): bool { return $this->status === LicenseStatus::Active && !$this->isExpired(); }
    protected static function newFactory(): LicenseFactory { return LicenseFactory::new(); }
}
```

- [ ] **Step 3: LicenseBinding + LicenseAudit modelleri**

Create `app/Domain/License/Models/LicenseBinding.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Models;

use App\Domain\License\Enums\BindingStatus;
use Database\Factories\LicenseBindingFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class LicenseBinding extends Model {
    use HasFactory;
    protected $fillable = ['license_id','bound_ip','bound_machine_id','bound_hostname','panel_version','first_seen_at','last_seen_at','status'];
    protected function casts(): array {
        return ['status' => BindingStatus::class, 'first_seen_at'=>'datetime', 'last_seen_at'=>'datetime'];
    }
    public function license(): BelongsTo { return $this->belongsTo(License::class); }
    protected static function newFactory(): LicenseBindingFactory { return LicenseBindingFactory::new(); }
}
```

Create `app/Domain/License/Models/LicenseAudit.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class LicenseAudit extends Model {
    public $timestamps = false;
    protected $table = 'license_audit';
    protected $fillable = ['license_id','event','payload','ip','user_agent','created_at'];
    protected function casts(): array {
        return ['payload' => 'array', 'created_at' => 'datetime'];
    }
    public function license(): BelongsTo { return $this->belongsTo(License::class); }
}
```

- [ ] **Step 4: Factory'ler**

Create `database/factories/LicenseFactory.php`:
```php
<?php
namespace Database\Factories;
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Enums\{LicenseStatus, LicenseTier};
use App\Domain\License\Models\License;
use Illuminate\Database\Eloquent\Factories\Factory;

class LicenseFactory extends Factory {
    protected $model = License::class;
    public function definition(): array {
        $tier = LicenseTier::Pro;
        return [
            'customer_id'   => Customer::factory(),
            'tier'          => $tier,
            'status'        => LicenseStatus::Active,
            'account_limit' => $tier->accountLimit(),
            'features'      => $tier->features(),
            'purchased_at'  => now(),
            'expires_at'    => now()->addYear(),
        ];
    }
    public function trial(): static { return $this->state(['tier' => LicenseTier::Trial, 'features' => LicenseTier::Trial->features(), 'account_limit' => 5]); }
    public function expired(): static { return $this->state(['status' => LicenseStatus::Expired, 'expires_at' => now()->subDay()]); }
    public function revoked(): static { return $this->state(['status' => LicenseStatus::Revoked, 'revoked_at' => now(), 'revoked_reason' => 'test']); }
}
```

Aynı pattern ile `LicenseBindingFactory`.

- [ ] **Step 5: Failing test**

Create `tests/Unit/License/LicenseModelTest.php`:
```php
<?php
use App\Domain\License\Enums\LicenseStatus;
use App\Domain\License\Models\License;

test('license oluşturulunca uuid otomatik atanır', function () {
    $l = License::factory()->create();
    expect($l->uuid)->toBeString()->toHaveLength(36);
});

test('aktif lisans isActive() true döner', function () {
    expect(License::factory()->create()->isActive())->toBeTrue();
});

test('expired lisans isActive() false döner', function () {
    expect(License::factory()->expired()->create()->isActive())->toBeFalse();
});

test('revoked lisans isActive() false döner', function () {
    expect(License::factory()->revoked()->create()->isActive())->toBeFalse();
});

test('aktif binding tek tane olur', function () {
    $l = License::factory()->create();
    \App\Domain\License\Models\LicenseBinding::factory()->count(3)->create(['license_id' => $l->id, 'status' => 'stale']);
    $active = \App\Domain\License\Models\LicenseBinding::factory()->create(['license_id' => $l->id, 'status' => 'active']);
    expect($l->activeBinding()->id)->toBe($active->id);
});
```

- [ ] **Step 6: Run test (zaten yeşil olmalı)**

Run: `./vendor/bin/pest tests/Unit/License/`
Expected: 5 passed.

- [ ] **Step 7: Commit**

```bash
git add app/Domain/License/ database/factories/ tests/
git commit -m "feat(license): License + Binding + Audit modelleri + factories + tests"
```

---

## Phase 2 — Public Pages & Auth UI (Tasks 5-6)

### Task 5: Public Layout + Home/Pricing/Features Pages

**Files:**
- Create: `resources/js/Layouts/PublicLayout.vue`
- Create: `resources/js/Pages/Public/{Home,Pricing,Features,Contact,Privacy,Terms,Kvkk}.vue`
- Create: `app/Http/Controllers/PublicController.php`
- Modify: `routes/web.php`
- Create: `tests/Feature/Smoke/PublicRoutesTest.php`

- [ ] **Step 1: PublicController**

Create `app/Http/Controllers/PublicController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Inertia\{Inertia, Response};

class PublicController extends Controller {
    public function home(): Response { return Inertia::render('Public/Home'); }
    public function pricing(): Response { return Inertia::render('Public/Pricing', ['tiers' => config('onoxsoft.tiers')]); }
    public function features(): Response { return Inertia::render('Public/Features'); }
    public function contact(): Response { return Inertia::render('Public/Contact'); }
    public function privacy(): Response { return Inertia::render('Public/Privacy'); }
    public function terms(): Response { return Inertia::render('Public/Terms'); }
    public function kvkk(): Response { return Inertia::render('Public/Kvkk'); }
}
```

- [ ] **Step 2: Routes**

Modify `routes/web.php`:
```php
<?php
use App\Http\Controllers\PublicController;
use Illuminate\Support\Facades\Route;

Route::get('/',           [PublicController::class, 'home'])->name('home');
Route::get('/fiyatlar',   [PublicController::class, 'pricing'])->name('pricing');
Route::get('/ozellikler', [PublicController::class, 'features'])->name('features');
Route::get('/iletisim',   [PublicController::class, 'contact'])->name('contact');
Route::get('/gizlilik',   [PublicController::class, 'privacy'])->name('privacy');
Route::get('/sozlesme',   [PublicController::class, 'terms'])->name('terms');
Route::get('/kvkk',       [PublicController::class, 'kvkk'])->name('kvkk');
```

- [ ] **Step 3: PublicLayout.vue**

Create `resources/js/Layouts/PublicLayout.vue` — header (logo + nav: Anasayfa/Fiyatlar/Özellikler/İletişim + "Giriş Yap"/"Kayıt Ol" buttons), main slot, footer (KVKK/Gizlilik/Sözleşme linkleri + sosyal medya).

```vue
<script setup>
import { Head, Link } from '@inertiajs/vue3'
defineProps({ title: { type: String, default: '' } })
</script>
<template>
    <Head :title="title" />
    <div class="min-h-screen bg-white">
        <header class="border-b border-slate-200 bg-white">
            <div class="mx-auto max-w-6xl flex items-center justify-between px-6 py-4">
                <Link href="/" class="flex items-center gap-2">
                    <img src="/images/onoxsoft-logo.svg" class="h-8" alt="Onoxsoft" />
                    <span class="font-bold text-lg">Onoxsoft</span>
                </Link>
                <nav class="hidden md:flex gap-8 text-sm font-medium text-slate-700">
                    <Link href="/" class="hover:text-indigo-600">Anasayfa</Link>
                    <Link href="/fiyatlar" class="hover:text-indigo-600">Fiyatlar</Link>
                    <Link href="/ozellikler" class="hover:text-indigo-600">Özellikler</Link>
                    <Link href="/iletisim" class="hover:text-indigo-600">İletişim</Link>
                </nav>
                <div class="flex gap-2">
                    <Link href="/login" class="px-4 py-2 text-sm font-medium text-slate-700 hover:text-indigo-600">Giriş</Link>
                    <Link href="/register" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Kayıt</Link>
                </div>
            </div>
        </header>
        <main><slot /></main>
        <footer class="border-t border-slate-200 mt-20 bg-slate-50">
            <div class="mx-auto max-w-6xl px-6 py-10 grid grid-cols-1 sm:grid-cols-4 gap-8 text-sm">
                <div>
                    <p class="font-bold text-slate-900 mb-3">Onoxsoft</p>
                    <p class="text-slate-600">Türkiye'nin yerli hosting kontrol paneli.</p>
                </div>
                <div>
                    <p class="font-semibold mb-3">Ürün</p>
                    <ul class="space-y-2 text-slate-600">
                        <li><Link href="/fiyatlar">Fiyatlar</Link></li>
                        <li><Link href="/ozellikler">Özellikler</Link></li>
                    </ul>
                </div>
                <div>
                    <p class="font-semibold mb-3">Hukuk</p>
                    <ul class="space-y-2 text-slate-600">
                        <li><Link href="/kvkk">KVKK</Link></li>
                        <li><Link href="/gizlilik">Gizlilik</Link></li>
                        <li><Link href="/sozlesme">Sözleşme</Link></li>
                    </ul>
                </div>
                <div>
                    <p class="font-semibold mb-3">İletişim</p>
                    <p class="text-slate-600">destek@onoxsoft.com</p>
                </div>
            </div>
            <div class="border-t border-slate-200 py-4 text-center text-xs text-slate-500">
                © 2026 ONOXSOFT Bilişim Ltd. Şti. — TPMK tescilli marka.
            </div>
        </footer>
    </div>
</template>
```

- [ ] **Step 4: Home.vue (hero + 3-column features)**

Create `resources/js/Pages/Public/Home.vue`:
```vue
<script setup>
import PublicLayout from '@/Layouts/PublicLayout.vue'
import { Link } from '@inertiajs/vue3'
</script>
<template>
    <PublicLayout title="Anasayfa">
        <section class="bg-gradient-to-b from-indigo-50 to-white py-20">
            <div class="mx-auto max-w-4xl px-6 text-center">
                <h1 class="text-5xl font-bold text-slate-900">Türkiye'nin Yerli Hosting Paneli</h1>
                <p class="mt-6 text-xl text-slate-600">cPanel/Plesk muadili — KVKK uyumlu, e-Fatura entegreli, Türkçe destekli.</p>
                <div class="mt-8 flex justify-center gap-3">
                    <Link href="/fiyatlar" class="rounded-lg bg-indigo-600 px-6 py-3 text-white font-semibold hover:bg-indigo-700">Fiyatları Gör</Link>
                    <Link href="/ozellikler" class="rounded-lg border border-slate-300 px-6 py-3 font-semibold hover:bg-slate-50">Özellikler</Link>
                </div>
            </div>
        </section>
        <section class="py-20">
            <div class="mx-auto max-w-6xl px-6 grid grid-cols-1 md:grid-cols-3 gap-8">
                <div class="rounded-xl border border-slate-200 p-6">
                    <h3 class="font-bold text-lg">KVKK Uyumlu</h3>
                    <p class="mt-2 text-sm text-slate-600">Audit log, PII maskeleme, retention politikaları yerleşik.</p>
                </div>
                <div class="rounded-xl border border-slate-200 p-6">
                    <h3 class="font-bold text-lg">e-Fatura Entegre</h3>
                    <p class="mt-2 text-sm text-slate-600">Paraşüt/Foriba ile otomatik fatura kesimi.</p>
                </div>
                <div class="rounded-xl border border-slate-200 p-6">
                    <h3 class="font-bold text-lg">Türkçe Destek</h3>
                    <p class="mt-2 text-sm text-slate-600">8-12 saat SLA, Discord topluluğu, video dokümantasyon.</p>
                </div>
            </div>
        </section>
    </PublicLayout>
</template>
```

- [ ] **Step 5: Pricing.vue (5 tier comparison)**

Create `resources/js/Pages/Public/Pricing.vue` — `props.tiers` (config'den) üzerinde döner, 5 kart çıkarır. Her kartta: tier adı, fiyat (₺), account limit, features liste, "Satın Al" butonu (`/register?tier={name}` veya direkt buy intent). Trial = ücretsiz.

```vue
<script setup>
import PublicLayout from '@/Layouts/PublicLayout.vue'
import { Link } from '@inertiajs/vue3'
defineProps({ tiers: Object })
const fmtPrice = (cents) => cents === 0 ? 'Ücretsiz' : '₺' + (cents/100).toLocaleString('tr-TR') + '/yıl'
</script>
<template>
    <PublicLayout title="Fiyatlar">
        <div class="mx-auto max-w-6xl px-6 py-16">
            <h1 class="text-4xl font-bold text-center">Plan Seç</h1>
            <p class="mt-3 text-center text-slate-600">Tüm planlar yıllık. KDV dahil. e-Fatura otomatik.</p>
            <div class="mt-12 grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
                <div v-for="(tier, key) in tiers" :key="key" class="rounded-xl border border-slate-200 p-6 flex flex-col">
                    <h3 class="font-bold text-lg capitalize">{{ key }}</h3>
                    <p class="mt-2 text-2xl font-bold text-indigo-600">{{ fmtPrice(tier.price) }}</p>
                    <p class="text-xs text-slate-500">{{ tier.account_limit === 0 ? 'Sınırsız hesap' : tier.account_limit + ' hesap' }}</p>
                    <ul class="mt-4 space-y-1 text-xs text-slate-600 flex-1">
                        <li v-for="f in tier.features" :key="f">✓ {{ f }}</li>
                    </ul>
                    <Link :href="`/register?tier=${key}`" class="mt-6 block rounded-lg bg-indigo-600 px-4 py-2 text-center text-white text-sm font-semibold">Satın Al</Link>
                </div>
            </div>
        </div>
    </PublicLayout>
</template>
```

- [ ] **Step 6: Diğer public sayfalar (Features, Contact, Privacy, Terms, Kvkk)**

Her biri PublicLayout kullanır, statik içerikle dolu. KVKK ve Sözleşme dosyaları kapsamlı (avukat sonra editleyecek). MVP için lorem-ipsum yerine 3-4 paragraf gerçek metin (KVKK için: "Veri sorumlusu: ONOXSOFT Bilişim Ltd. Şti., adres: ..., e-posta: kvkk@onoxsoft.com, ..." — yer tutucu içerik avukat onayına gönderilecek not düşülür).

- [ ] **Step 7: Smoke test**

Create `tests/Feature/Smoke/PublicRoutesTest.php`:
```php
<?php
test('public routes 200 döner', function () {
    foreach (['/', '/fiyatlar', '/ozellikler', '/iletisim', '/gizlilik', '/sozlesme', '/kvkk'] as $url) {
        $this->get($url)->assertOk();
    }
});

test('fiyatlar sayfası tier verisini paylaşır', function () {
    $this->get('/fiyatlar')
        ->assertOk()
        ->assertInertia(fn($p) => $p->component('Public/Pricing')->has('tiers.pro.price'));
});
```

- [ ] **Step 8: Run + commit**

```bash
./vendor/bin/pest tests/Feature/Smoke/PublicRoutesTest.php
git add app/Http/Controllers/PublicController.php routes/web.php resources/js/ tests/
git commit -m "feat(public): anasayfa + fiyatlar + ozellikler + KVKK/sozlesme/iletisim sayfaları"
```

---

### Task 6: Auth UI (Login/Register/Forgot/Reset/2FA)

**Files:**
- Create: `resources/js/Pages/Auth/{Login,Register,Forgot,Reset,TwoFactor,VerifyEmail}.vue`
- Create: `resources/js/Layouts/AuthLayout.vue`
- Create: `tests/Feature/Auth/{LoginTest,TwoFactorTest}.php`

- [ ] **Step 1: AuthLayout — ortalanmış logo + slot card**

Create `resources/js/Layouts/AuthLayout.vue`:
```vue
<script setup>
import { Head, Link } from '@inertiajs/vue3'
defineProps({ title: String })
</script>
<template>
    <Head :title="title" />
    <div class="min-h-screen bg-slate-50 flex flex-col items-center justify-center p-6">
        <Link href="/" class="mb-8 flex items-center gap-2">
            <img src="/images/onoxsoft-logo.svg" class="h-10" />
            <span class="text-xl font-bold">Onoxsoft</span>
        </Link>
        <div class="w-full max-w-md rounded-xl bg-white border border-slate-200 shadow-sm p-8">
            <slot />
        </div>
        <p class="mt-6 text-xs text-slate-500">© ONOXSOFT — Türkiye'nin yerli hosting paneli</p>
    </div>
</template>
```

- [ ] **Step 2: Register.vue — KVKK checkbox dahil**

Create `resources/js/Pages/Auth/Register.vue`:
```vue
<script setup>
import AuthLayout from '@/Layouts/AuthLayout.vue'
import { Link, useForm } from '@inertiajs/vue3'

const props = defineProps({ tier: { type: String, default: null } })

const form = useForm({
    name: '',
    email: '',
    password: '',
    password_confirmation: '',
    kvkk_consent: false,
    tier: props.tier,
})

const submit = () => form.post('/register')
</script>
<template>
    <AuthLayout title="Kayıt Ol">
        <h1 class="text-2xl font-bold mb-6">Hesap Oluştur</h1>
        <form @submit.prevent="submit" class="space-y-4">
            <div>
                <label class="block text-sm font-medium mb-1">Ad Soyad / Firma</label>
                <input v-model="form.name" type="text" required class="w-full rounded-lg border border-slate-300 px-3 py-2" />
                <p v-if="form.errors.name" class="text-xs text-red-600 mt-1">{{ form.errors.name }}</p>
            </div>
            <div>
                <label class="block text-sm font-medium mb-1">E-posta</label>
                <input v-model="form.email" type="email" required class="w-full rounded-lg border border-slate-300 px-3 py-2" />
                <p v-if="form.errors.email" class="text-xs text-red-600 mt-1">{{ form.errors.email }}</p>
            </div>
            <div>
                <label class="block text-sm font-medium mb-1">Şifre</label>
                <input v-model="form.password" type="password" required class="w-full rounded-lg border border-slate-300 px-3 py-2" />
                <p v-if="form.errors.password" class="text-xs text-red-600 mt-1">{{ form.errors.password }}</p>
            </div>
            <div>
                <label class="block text-sm font-medium mb-1">Şifre tekrar</label>
                <input v-model="form.password_confirmation" type="password" required class="w-full rounded-lg border border-slate-300 px-3 py-2" />
            </div>
            <label class="flex items-start gap-2 text-sm">
                <input v-model="form.kvkk_consent" type="checkbox" class="mt-1" required />
                <span><Link href="/kvkk" class="underline">KVKK Aydınlatma Metni</Link>'ni okudum, onaylıyorum.</span>
            </label>
            <p v-if="form.errors.kvkk_consent" class="text-xs text-red-600">{{ form.errors.kvkk_consent }}</p>
            <button :disabled="form.processing" class="w-full rounded-lg bg-indigo-600 text-white py-2.5 font-semibold disabled:opacity-50">Kayıt Ol</button>
            <p class="text-center text-sm text-slate-600">Zaten üye? <Link href="/login" class="text-indigo-600 font-medium">Giriş yap</Link></p>
        </form>
    </AuthLayout>
</template>
```

- [ ] **Step 3: Login.vue, Forgot.vue, Reset.vue, TwoFactor.vue, VerifyEmail.vue**

Aynı pattern ile 5 sayfa daha. Login `email`+`password`+`remember`+`Link href="/forgot"`. Forgot sadece e-posta input. Reset token+email+yeni şifre. TwoFactor `code` input. VerifyEmail "e-posta doğrulama linki gönderildi" notice.

- [ ] **Step 4: LoginTest yaz**

Create `tests/Feature/Auth/LoginTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;

test('müşteri giriş yapabilir', function () {
    $c = Customer::factory()->create(['password' => bcrypt('Sifre1234!')]);
    $this->post('/login', ['email' => $c->email, 'password' => 'Sifre1234!'])
        ->assertRedirect('/portal');
    $this->assertAuthenticatedAs($c);
});

test('yanlış şifre 422 döner', function () {
    $c = Customer::factory()->create();
    $this->post('/login', ['email' => $c->email, 'password' => 'wrong'])
        ->assertSessionHasErrors('email');
});
```

- [ ] **Step 5: Run + commit**

```bash
./vendor/bin/pest tests/Feature/Auth/
git add resources/js/Layouts/AuthLayout.vue resources/js/Pages/Auth/ tests/
git commit -m "feat(auth-ui): login/register/forgot/reset/2FA Vue sayfaları + tests"
```

---

## Phase 3 — Purchase Flow (Tasks 7-10)

### Task 7: Purchase Intent — Token Verifier + Model

**Files:**
- Create: `app/Domain/License/Models/PurchaseIntent.php`
- Create: `app/Domain/License/Services/PurchaseIntentVerifier.php`
- Create: `database/factories/PurchaseIntentFactory.php`
- Create: `tests/Unit/License/PurchaseIntentVerifierTest.php`

- [ ] **Step 1: PurchaseIntent model**

Create `app/Domain/License/Models/PurchaseIntent.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Models;

use App\Domain\Customer\Models\Customer;
use App\Domain\Order\Models\Order;
use Database\Factories\PurchaseIntentFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class PurchaseIntent extends Model {
    use HasFactory;
    protected $fillable = ['token','panel_ip','panel_machine_id','panel_hostname','panel_version','customer_id','order_id','expires_at','consumed_at'];
    protected function casts(): array {
        return ['expires_at' => 'datetime', 'consumed_at' => 'datetime'];
    }
    public function customer(): BelongsTo { return $this->belongsTo(Customer::class); }
    public function order(): BelongsTo { return $this->belongsTo(Order::class); }
    public function isExpired(): bool { return $this->expires_at->isPast(); }
    public function isConsumed(): bool { return $this->consumed_at !== null; }
    protected static function newFactory(): PurchaseIntentFactory { return PurchaseIntentFactory::new(); }
}
```

- [ ] **Step 2: PurchaseIntentVerifier service**

Create `app/Domain/License/Services/PurchaseIntentVerifier.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Services;

class PurchaseIntentVerifier {
    public function __construct(private string $secret) {}

    /**
     * Token formatı (base64url): JSON({v,ip,mid,host,pv,ts,exp,nonce,sig})
     * sig = HMAC-SHA256(JSON_WITHOUT_SIG, secret)
     */
    public function parse(string $base64Token): ?array {
        try {
            $json = base64_decode(strtr($base64Token, '-_', '+/'), true);
            if (!$json) return null;
            $data = json_decode($json, true);
            if (!is_array($data) || !isset($data['sig'])) return null;

            $sig = $data['sig'];
            unset($data['sig']);
            $expected = hash_hmac('sha256', json_encode($data, JSON_UNESCAPED_SLASHES), $this->secret);
            if (!hash_equals($expected, $sig)) return null;

            if (($data['exp'] ?? 0) < time()) return null;
            return $data;
        } catch (\Throwable) {
            return null;
        }
    }

    public function isValid(string $base64Token): bool {
        return $this->parse($base64Token) !== null;
    }
}
```

- [ ] **Step 3: Service binding**

Modify `app/Providers/AppServiceProvider.php` `register()`:
```php
$this->app->singleton(\App\Domain\License\Services\PurchaseIntentVerifier::class, fn() =>
    new \App\Domain\License\Services\PurchaseIntentVerifier(config('onoxsoft.intent.secret'))
);
```

- [ ] **Step 4: Test secret üret + .env**

Run:
```bash
php -r "echo bin2hex(random_bytes(32));"
# Output'u kopyala
```

Modify `.env`:
```
ONOXSOFT_INTENT_SECRET=<yukarıdaki hex>
```

- [ ] **Step 5: Failing test**

Create `tests/Unit/License/PurchaseIntentVerifierTest.php`:
```php
<?php
use App\Domain\License\Services\PurchaseIntentVerifier;

beforeEach(function () {
    $this->verifier = new PurchaseIntentVerifier('test-secret-32-bytes-fixed-value');
});

function makeToken(array $data, string $secret = 'test-secret-32-bytes-fixed-value'): string {
    $data['sig'] = hash_hmac('sha256', json_encode($data, JSON_UNESCAPED_SLASHES), $secret);
    return rtrim(strtr(base64_encode(json_encode($data)), '+/', '-_'), '=');
}

test('geçerli token parse edilir', function () {
    $token = makeToken([
        'v'=>1, 'ip'=>'1.2.3.4', 'mid'=>'abc', 'host'=>'h.com',
        'pv'=>'1.0', 'ts'=>time(), 'exp'=>time()+3600, 'nonce'=>'n1',
    ]);
    $data = $this->verifier->parse($token);
    expect($data)->not->toBeNull();
    expect($data['ip'])->toBe('1.2.3.4');
});

test('geçersiz imza reddedilir', function () {
    $token = makeToken(['v'=>1,'ip'=>'1.2.3.4','mid'=>'abc','host'=>'h','pv'=>'1','ts'=>time(),'exp'=>time()+3600,'nonce'=>'n']);
    // Token'ı boz
    $verifier = new PurchaseIntentVerifier('FARKLI-SECRET');
    expect($verifier->parse($token))->toBeNull();
});

test('expired token reddedilir', function () {
    $token = makeToken(['v'=>1,'ip'=>'1','mid'=>'a','host'=>'h','pv'=>'1','ts'=>1,'exp'=>1,'nonce'=>'n']);
    expect($this->verifier->parse($token))->toBeNull();
});

test('bozuk base64 reddedilir', function () {
    expect($this->verifier->parse('!!!invalid!!!'))->toBeNull();
});
```

- [ ] **Step 6: Run + commit**

```bash
./vendor/bin/pest tests/Unit/License/PurchaseIntentVerifierTest.php
git add app/Domain/License/Models/PurchaseIntent.php app/Domain/License/Services/PurchaseIntentVerifier.php app/Providers/AppServiceProvider.php database/factories/ tests/
git commit -m "feat(intent): PurchaseIntent modeli + HMAC token doğrulayıcı + tests"
```

---

### Task 8: Buy Page (/buy?intent=TOKEN)

**Files:**
- Create: `app/Http/Controllers/BuyController.php`
- Create: `resources/js/Pages/Buy/Index.vue`
- Modify: `routes/web.php`
- Create: `tests/Feature/BuyFlowTest.php`

- [ ] **Step 1: BuyController**

Create `app/Http/Controllers/BuyController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Domain\License\Models\PurchaseIntent;
use App\Domain\License\Services\PurchaseIntentVerifier;
use Illuminate\Http\Request;
use Inertia\{Inertia, Response};

class BuyController extends Controller {
    public function __construct(private PurchaseIntentVerifier $verifier) {}

    public function show(Request $request): Response {
        $token = $request->query('intent');
        $intent = null;

        if ($token) {
            $parsed = $this->verifier->parse($token);
            if ($parsed) {
                $intent = PurchaseIntent::firstOrCreate(
                    ['token' => $token],
                    [
                        'panel_ip'         => $parsed['ip'],
                        'panel_machine_id' => $parsed['mid'],
                        'panel_hostname'   => $parsed['host'] ?? null,
                        'panel_version'    => $parsed['pv'] ?? null,
                        'expires_at'       => now()->createFromTimestamp($parsed['exp']),
                    ]
                );
            }
        }

        return Inertia::render('Buy/Index', [
            'intent' => $intent ? [
                'token'     => $intent->token,
                'ip'        => $intent->panel_ip,
                'host'      => $intent->panel_hostname,
                'expires_at'=> $intent->expires_at->toIso8601String(),
                'expired'   => $intent->isExpired(),
            ] : null,
            'tiers'  => config('onoxsoft.tiers'),
            'currentCustomer' => $request->user() ? [
                'id' => $request->user()->id, 'email' => $request->user()->email, 'name' => $request->user()->name,
            ] : null,
        ]);
    }
}
```

- [ ] **Step 2: Route**

Modify `routes/web.php`:
```php
Route::get('/buy', [App\Http\Controllers\BuyController::class, 'show'])->name('buy');
```

- [ ] **Step 3: Buy/Index.vue — plan seçim + intent gösterimi**

Create `resources/js/Pages/Buy/Index.vue` — solda intent token bilgileri (IP, hostname, expires countdown), sağda 5 plan kartı, "Satın Al" butonu Order oluşturup `/buy/{order}/checkout`'a yönlendirir. Mevcut müşteri login ise direkt order, değilse register modal.

(Bu component ~150 satır; sayfa şablonu PANELTR'deki Account Create/Show pattern'ine benzer.)

- [ ] **Step 4: Test**

Create `tests/Feature/BuyFlowTest.php`:
```php
<?php
use App\Domain\License\Models\PurchaseIntent;
use App\Domain\License\Services\PurchaseIntentVerifier;

test('intent token ile buy sayfası açılır', function () {
    config(['onoxsoft.intent.secret' => 'test-secret-key-32-bytes-fixed!!']);
    app()->instance(PurchaseIntentVerifier::class, new PurchaseIntentVerifier('test-secret-key-32-bytes-fixed!!'));

    $data = ['v'=>1,'ip'=>'5.6.7.8','mid'=>'mid123','host'=>'p.com','pv'=>'1.0','ts'=>time(),'exp'=>time()+3600,'nonce'=>'n1'];
    $data['sig'] = hash_hmac('sha256', json_encode($data, JSON_UNESCAPED_SLASHES), 'test-secret-key-32-bytes-fixed!!');
    $token = rtrim(strtr(base64_encode(json_encode($data)), '+/', '-_'), '=');

    $this->get('/buy?intent='.$token)
        ->assertOk()
        ->assertInertia(fn($p) => $p->component('Buy/Index')->has('intent.ip')->where('intent.ip', '5.6.7.8'));

    expect(PurchaseIntent::where('token', $token)->exists())->toBeTrue();
});

test('intent yoksa buy sayfası tier listesi gösterir (manual purchase)', function () {
    $this->get('/buy')->assertOk()->assertInertia(fn($p) => $p->component('Buy/Index')->where('intent', null));
});

test('geçersiz intent token reddedilir', function () {
    $this->get('/buy?intent=BOZUK')->assertOk()->assertInertia(fn($p) => $p->where('intent', null));
});
```

- [ ] **Step 5: Run + commit**

```bash
./vendor/bin/pest tests/Feature/BuyFlowTest.php
git add app/Http/Controllers/BuyController.php resources/js/Pages/Buy/Index.vue routes/web.php tests/
git commit -m "feat(buy): /buy?intent=TOKEN sayfası — plan seçim + intent parse + tests"
```

---

### Task 9: Order Service + Iyzico Gateway

**Files:**
- Create: `app/Domain/Order/Models/Order.php`
- Create: `app/Domain/Order/Services/{IyzicoGateway,OrderService}.php`
- Create: `app/Http/Controllers/CheckoutController.php`
- Create: `app/Jobs/ProcessIyzicoCallbackJob.php`
- Modify: `routes/web.php`
- Create: `tests/Feature/CheckoutTest.php`

- [ ] **Step 1: Order modeli**

Create `app/Domain/Order/Models/Order.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\Order\Models;

use App\Domain\Customer\Models\Customer;
use App\Domain\License\Models\License;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Order extends Model {
    use HasFactory;
    protected $fillable = ['customer_id','license_id','amount_cents','currency','status','iyzico_payment_id','iyzico_basket_id','intent_token','payload','paid_at'];
    protected function casts(): array {
        return ['payload' => 'array', 'paid_at' => 'datetime'];
    }
    public function customer(): BelongsTo { return $this->belongsTo(Customer::class); }
    public function license(): BelongsTo { return $this->belongsTo(License::class); }
}
```

- [ ] **Step 2: IyzicoGateway service**

Create `app/Domain/Order/Services/IyzicoGateway.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\Order\Services;

use App\Domain\Customer\Models\Customer;
use App\Domain\Order\Models\Order;
use Iyzipay\Model\{Address, BasketItem, BasketItemType, Buyer, Locale, PaymentChannel, PaymentGroup, Currency, ThreedsInitialize};
use Iyzipay\Options;
use Iyzipay\Request\CreatePaymentRequest;
use Iyzipay\Model\ThreedsPayment;
use Iyzipay\Request\CreateThreedsPaymentRequest;

class IyzicoGateway {
    private Options $options;

    public function __construct() {
        $this->options = new Options();
        $this->options->setApiKey(config('onoxsoft.iyzico.api_key'));
        $this->options->setSecretKey(config('onoxsoft.iyzico.secret'));
        $this->options->setBaseUrl(config('onoxsoft.iyzico.base_url'));
    }

    public function initiate3DS(Order $order, Customer $customer, array $card): ThreedsInitialize {
        $req = new CreatePaymentRequest();
        $req->setLocale(Locale::TR);
        $req->setConversationId((string) $order->id);
        $req->setPrice((string) ($order->amount_cents / 100));
        $req->setPaidPrice((string) ($order->amount_cents / 100));
        $req->setCurrency(Currency::TL);
        $req->setInstallment(1);
        $req->setBasketId($order->iyzico_basket_id);
        $req->setPaymentChannel(PaymentChannel::WEB);
        $req->setPaymentGroup(PaymentGroup::SUBSCRIPTION);
        $req->setCallbackUrl(route('checkout.callback', $order));

        $req->setPaymentCard($this->buildCard($card));
        $req->setBuyer($this->buildBuyer($customer, $order));
        $req->setBillingAddress($this->buildAddress($customer));
        $req->setShippingAddress($this->buildAddress($customer));
        $req->setBasketItems([$this->buildBasketItem($order)]);

        return ThreedsInitialize::create($req, $this->options);
    }

    public function complete3DS(string $paymentId, string $conversationId): ThreedsPayment {
        $req = new CreateThreedsPaymentRequest();
        $req->setLocale(Locale::TR);
        $req->setConversationId($conversationId);
        $req->setPaymentId($paymentId);
        return ThreedsPayment::create($req, $this->options);
    }

    private function buildCard(array $card) {
        $c = new \Iyzipay\Model\PaymentCard();
        $c->setCardHolderName($card['holder']);
        $c->setCardNumber($card['number']);
        $c->setExpireMonth($card['exp_month']);
        $c->setExpireYear($card['exp_year']);
        $c->setCvc($card['cvc']);
        $c->setRegisterCard(0);
        return $c;
    }

    private function buildBuyer(Customer $c, Order $order): Buyer {
        $b = new Buyer();
        $b->setId((string) $c->id);
        $b->setName(explode(' ', $c->name)[0] ?? 'Müşteri');
        $b->setSurname(implode(' ', array_slice(explode(' ', $c->name), 1)) ?: 'Müşteri');
        $b->setEmail($c->email);
        $b->setGsmNumber($c->phone ?? '+905000000000');
        $b->setIdentityNumber($c->tax_number ?? '11111111111');
        $b->setRegistrationAddress($c->billing_address['address'] ?? 'Adres yok');
        $b->setIp(request()->ip());
        $b->setCity($c->billing_address['city'] ?? 'Istanbul');
        $b->setCountry('Turkey');
        return $b;
    }

    private function buildAddress(Customer $c): Address {
        $a = new Address();
        $a->setContactName($c->name);
        $a->setCity($c->billing_address['city'] ?? 'Istanbul');
        $a->setCountry('Turkey');
        $a->setAddress($c->billing_address['address'] ?? 'Adres yok');
        return $a;
    }

    private function buildBasketItem(Order $order): BasketItem {
        $i = new BasketItem();
        $i->setId((string) $order->id);
        $i->setName('Onoxsoft Panel — '.($order->payload['tier'] ?? 'pro'));
        $i->setCategory1('Yazılım Lisansı');
        $i->setItemType(BasketItemType::VIRTUAL);
        $i->setPrice((string) ($order->amount_cents / 100));
        return $i;
    }
}
```

- [ ] **Step 3: OrderService — createPending**

Create `app/Domain/Order/Services/OrderService.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\Order\Services;

use App\Domain\Customer\Models\Customer;
use App\Domain\License\Enums\LicenseTier;
use App\Domain\License\Models\PurchaseIntent;
use App\Domain\Order\Models\Order;
use Illuminate\Support\Str;

class OrderService {
    public function createPending(Customer $customer, LicenseTier $tier, ?PurchaseIntent $intent = null): Order {
        return Order::create([
            'customer_id'       => $customer->id,
            'amount_cents'      => $tier->priceCents(),
            'currency'          => 'TRY',
            'status'            => 'pending',
            'iyzico_basket_id'  => Str::random(16),
            'intent_token'      => $intent?->token,
            'payload'           => [
                'tier' => $tier->value,
                'features' => $tier->features(),
                'account_limit' => $tier->accountLimit(),
            ],
        ]);
    }
}
```

- [ ] **Step 4: CheckoutController — initiate + callback**

Create `app/Http/Controllers/CheckoutController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;

use App\Domain\License\Enums\LicenseTier;
use App\Domain\License\Models\PurchaseIntent;
use App\Domain\License\Services\LicenseIssuer;
use App\Domain\Order\Models\Order;
use App\Domain\Order\Services\{IyzicoGateway, OrderService};
use Illuminate\Http\{Request, RedirectResponse};
use Inertia\{Inertia, Response};

class CheckoutController extends Controller {
    public function __construct(
        private OrderService $orders,
        private IyzicoGateway $iyzico,
        private LicenseIssuer $issuer,
    ) {}

    public function start(Request $request): RedirectResponse {
        $data = $request->validate([
            'tier' => 'required|in:trial,lite,pro,unlimited,enterprise',
            'intent_token' => 'nullable|string',
        ]);
        $tier = LicenseTier::from($data['tier']);
        $intent = $data['intent_token'] ? PurchaseIntent::where('token', $data['intent_token'])->first() : null;
        $order = $this->orders->createPending($request->user(), $tier, $intent);

        return redirect()->route('checkout.show', $order);
    }

    public function show(Order $order): Response {
        abort_if($order->customer_id !== auth()->id(), 403);
        abort_if($order->status !== 'pending', 410, 'Order kapalı');
        return Inertia::render('Buy/Checkout', ['order' => $order]);
    }

    public function process(Request $request, Order $order) {
        abort_if($order->customer_id !== auth()->id(), 403);
        $card = $request->validate([
            'holder'=>'required', 'number'=>'required', 'exp_month'=>'required',
            'exp_year'=>'required', 'cvc'=>'required',
        ]);
        $res = $this->iyzico->initiate3DS($order, $request->user(), $card);
        if ($res->getStatus() === 'success') {
            return response()->json(['threeDSHtmlContent' => base64_decode($res->getHtmlContent())]);
        }
        return response()->json(['error' => $res->getErrorMessage()], 422);
    }

    public function callback(Request $request, Order $order) {
        $paymentId = $request->input('paymentId');
        $conversationId = $request->input('conversationId');
        $res = $this->iyzico->complete3DS($paymentId, $conversationId);

        if ($res->getStatus() === 'success' && $res->getPaymentStatus() === 'SUCCESS') {
            $order->update(['status' => 'paid', 'iyzico_payment_id' => $paymentId, 'paid_at' => now()]);
            $license = $this->issuer->issueFromOrder($order);
            return redirect()->route('checkout.success', $order);
        }
        $order->update(['status' => 'failed']);
        return redirect()->route('checkout.failure', $order);
    }

    public function success(Order $order): Response {
        return Inertia::render('Buy/Success', ['order' => $order->load('license')]);
    }

    public function failure(Order $order): Response {
        return Inertia::render('Buy/Failure', ['order' => $order]);
    }
}
```

- [ ] **Step 5: Routes**

Modify `routes/web.php`:
```php
Route::middleware('auth')->group(function () {
    Route::post('/checkout/start', [\App\Http\Controllers\CheckoutController::class, 'start'])->name('checkout.start');
    Route::get('/checkout/{order}', [\App\Http\Controllers\CheckoutController::class, 'show'])->name('checkout.show');
    Route::post('/checkout/{order}/process', [\App\Http\Controllers\CheckoutController::class, 'process'])->name('checkout.process');
    Route::get('/checkout/{order}/success', [\App\Http\Controllers\CheckoutController::class, 'success'])->name('checkout.success');
    Route::get('/checkout/{order}/failure', [\App\Http\Controllers\CheckoutController::class, 'failure'])->name('checkout.failure');
});
Route::any('/checkout/{order}/callback', [\App\Http\Controllers\CheckoutController::class, 'callback'])->name('checkout.callback');
```

- [ ] **Step 6: Test (Iyzico mocked)**

Create `tests/Feature/CheckoutTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Enums\LicenseTier;
use App\Domain\Order\Models\Order;
use App\Domain\Order\Services\OrderService;

test('müşteri pending order oluşturabilir', function () {
    $c = Customer::factory()->create();
    $this->actingAs($c)
        ->post('/checkout/start', ['tier' => 'pro'])
        ->assertRedirect();

    $order = Order::where('customer_id', $c->id)->first();
    expect($order)->not->toBeNull();
    expect($order->status)->toBe('pending');
    expect($order->amount_cents)->toBe(LicenseTier::Pro->priceCents());
});

test('başka müşterinin order checkout sayfası 403', function () {
    $owner = Customer::factory()->create();
    $other = Customer::factory()->create();
    $order = app(OrderService::class)->createPending($owner, LicenseTier::Pro);
    $this->actingAs($other)->get('/checkout/'.$order->id)->assertForbidden();
});
```

(3D-Secure flow integration test L1 sonunda end-to-end test'te yapılacak — Iyzico sandbox kredi kartlarıyla manuel.)

- [ ] **Step 7: Vue Buy/Checkout.vue + Buy/Success.vue + Buy/Failure.vue**

Checkout: kart bilgileri formu (Iyzico Test kartları: `5528790000000008/12/30/123`). `axios.post('/checkout/{id}/process')` → response'tan gelen `threeDSHtmlContent`'i iframe'e basar (3DS bankası ekranı). Banka callback'i `/checkout/{id}/callback`'e döner.

- [ ] **Step 8: Run + commit**

```bash
./vendor/bin/pest tests/Feature/CheckoutTest.php
git add app/Domain/Order/ app/Http/Controllers/CheckoutController.php resources/js/Pages/Buy/ routes/web.php tests/
git commit -m "feat(checkout): Order + Iyzico 3DS gateway + checkout controller + sandbox testler"
```

---

### Task 10: Ed25519 Keypair + JWT Signer

**Files:**
- Create: `app/Console/Commands/LicenseKeygenCommand.php`
- Create: `app/Domain/License/Services/JwtSigner.php`
- Create: `tests/Unit/License/JwtSignerTest.php`

- [ ] **Step 1: Keygen artisan command**

Create `app/Console/Commands/LicenseKeygenCommand.php`:
```php
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;

class LicenseKeygenCommand extends Command {
    protected $signature = 'onoxsoft:license-keygen {--force}';
    protected $description = 'Ed25519 license keypair üret (.env değişkenleri için base64 çıktısı)';

    public function handle(): int {
        if (!extension_loaded('sodium')) {
            $this->error('sodium extension yüklenmemiş'); return 1;
        }
        $kp = sodium_crypto_sign_keypair();
        $secret = sodium_crypto_sign_secretkey($kp);
        $public = sodium_crypto_sign_publickey($kp);

        $this->info('ONOXSOFT_LICENSE_PRIVATE_KEY='.base64_encode($secret));
        $this->info('ONOXSOFT_LICENSE_PUBLIC_KEY='.base64_encode($public));
        $this->newLine();
        $this->warn('İki değişkeni .env\'e ekle. Private key gizli, public key panele dağıt.');
        return 0;
    }
}
```

- [ ] **Step 2: Çalıştır + .env'i doldur**

Run:
```bash
php artisan onoxsoft:license-keygen
# Çıktıyı .env'e ekle:
# ONOXSOFT_LICENSE_PRIVATE_KEY=BASE64...
# ONOXSOFT_LICENSE_PUBLIC_KEY=BASE64...
```

- [ ] **Step 3: JwtSigner service**

Create `app/Domain/License/Services/JwtSigner.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Services;

class JwtSigner {
    public function __construct(
        private string $privateKey,
        private string $publicKey,
    ) {}

    public function sign(array $claims): string {
        $header  = ['alg' => 'EdDSA', 'typ' => 'JWT'];
        $payload = $claims;
        $headerB64  = $this->base64url(json_encode($header,  JSON_UNESCAPED_SLASHES));
        $payloadB64 = $this->base64url(json_encode($payload, JSON_UNESCAPED_SLASHES));
        $message    = $headerB64.'.'.$payloadB64;
        $signature  = sodium_crypto_sign_detached($message, $this->privateKey);
        return $message.'.'.$this->base64url($signature);
    }

    public function verify(string $jwt): ?array {
        $parts = explode('.', $jwt);
        if (count($parts) !== 3) return null;
        [$h, $p, $sig] = $parts;
        $message = $h.'.'.$p;
        $signature = $this->base64urlDecode($sig);
        if (!$signature || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) return null;
        try {
            if (!sodium_crypto_sign_verify_detached($signature, $message, $this->publicKey)) return null;
        } catch (\Throwable) { return null; }
        $claims = json_decode($this->base64urlDecode($p), true);
        if (!is_array($claims)) return null;
        if (isset($claims['exp']) && $claims['exp'] < time()) return null;
        return $claims;
    }

    private function base64url(string $data): string {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }
    private function base64urlDecode(string $data): string {
        return base64_decode(strtr($data, '-_', '+/'), true) ?: '';
    }
}
```

- [ ] **Step 4: Service container binding**

Modify `app/Providers/AppServiceProvider.php` `register()`:
```php
$this->app->singleton(\App\Domain\License\Services\JwtSigner::class, function () {
    return new \App\Domain\License\Services\JwtSigner(
        base64_decode(config('onoxsoft.license.private_key')),
        base64_decode(config('onoxsoft.license.public_key')),
    );
});
```

- [ ] **Step 5: Failing test**

Create `tests/Unit/License/JwtSignerTest.php`:
```php
<?php
use App\Domain\License\Services\JwtSigner;

beforeEach(function () {
    $kp = sodium_crypto_sign_keypair();
    $this->signer = new JwtSigner(
        sodium_crypto_sign_secretkey($kp),
        sodium_crypto_sign_publickey($kp),
    );
});

test('imzalanmış JWT verify edilir', function () {
    $jwt = $this->signer->sign(['sub' => 'license-uuid', 'exp' => time()+3600]);
    $claims = $this->signer->verify($jwt);
    expect($claims)->not->toBeNull();
    expect($claims['sub'])->toBe('license-uuid');
});

test('expired JWT reddedilir', function () {
    $jwt = $this->signer->sign(['sub' => 'x', 'exp' => time()-1]);
    expect($this->signer->verify($jwt))->toBeNull();
});

test('tampered JWT reddedilir', function () {
    $jwt = $this->signer->sign(['sub' => 'x', 'exp' => time()+3600]);
    [$h,$p,$s] = explode('.', $jwt);
    // payload'ı değiştir
    $tampered = $h.'.'.rtrim(strtr(base64_encode(json_encode(['sub'=>'evil','exp'=>time()+3600])), '+/', '-_'), '=').'.'.$s;
    expect($this->signer->verify($tampered))->toBeNull();
});

test('farklı public key reddedilir', function () {
    $jwt = $this->signer->sign(['sub' => 'x', 'exp' => time()+3600]);
    $kp2 = sodium_crypto_sign_keypair();
    $signer2 = new JwtSigner(sodium_crypto_sign_secretkey($kp2), sodium_crypto_sign_publickey($kp2));
    expect($signer2->verify($jwt))->toBeNull();
});
```

- [ ] **Step 6: Run + commit**

```bash
./vendor/bin/pest tests/Unit/License/JwtSignerTest.php
git add app/Console/Commands/LicenseKeygenCommand.php app/Domain/License/Services/JwtSigner.php app/Providers/AppServiceProvider.php tests/
git commit -m "feat(jwt): Ed25519 keypair gen + JwtSigner sign/verify + tests"
```

---

## Phase 4 — License Lifecycle (Tasks 11-13)

### Task 11: LicenseIssuer Service

**Files:**
- Create: `app/Domain/License/Services/LicenseIssuer.php`
- Create: `app/Domain/License/Actions/IssueLicenseFromOrderAction.php`
- Create: `app/Domain/License/Events/LicenseIssued.php`
- Create: `tests/Feature/License/LicenseIssuerTest.php`

- [ ] **Step 1: Event class**

Create `app/Domain/License/Events/LicenseIssued.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Events;
use App\Domain\License\Models\License;
use Illuminate\Foundation\Events\Dispatchable;

class LicenseIssued {
    use Dispatchable;
    public function __construct(public License $license) {}
}
```

- [ ] **Step 2: LicenseIssuer service**

Create `app/Domain/License/Services/LicenseIssuer.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Services;

use App\Domain\License\Enums\{LicenseStatus, LicenseTier};
use App\Domain\License\Events\LicenseIssued;
use App\Domain\License\Models\{License, LicenseAudit};
use App\Domain\Order\Models\Order;
use Illuminate\Support\Facades\DB;

class LicenseIssuer {
    public function issueFromOrder(Order $order): License {
        return DB::transaction(function () use ($order) {
            $tier = LicenseTier::from($order->payload['tier']);
            $license = License::create([
                'customer_id'   => $order->customer_id,
                'tier'          => $tier,
                'status'        => LicenseStatus::Active,
                'account_limit' => $tier->accountLimit(),
                'features'      => $tier->features(),
                'purchased_at'  => $order->paid_at ?? now(),
                'expires_at'    => ($order->paid_at ?? now())->copy()->addYear(),
            ]);
            $order->update(['license_id' => $license->id]);

            LicenseAudit::create([
                'license_id' => $license->id,
                'event'      => 'activate',
                'payload'    => ['order_id' => $order->id, 'tier' => $tier->value],
                'ip'         => request()?->ip(),
                'user_agent' => request()?->userAgent(),
                'created_at' => now(),
            ]);

            event(new LicenseIssued($license));
            return $license;
        });
    }

    public function issueManual(int $customerId, LicenseTier $tier, int $durationDays = 365): License {
        $license = License::create([
            'customer_id'   => $customerId,
            'tier'          => $tier,
            'status'        => LicenseStatus::Active,
            'account_limit' => $tier->accountLimit(),
            'features'      => $tier->features(),
            'purchased_at'  => now(),
            'expires_at'    => now()->addDays($durationDays),
        ]);

        LicenseAudit::create([
            'license_id' => $license->id,
            'event'      => 'activate',
            'payload'    => ['manual' => true, 'tier' => $tier->value],
            'created_at' => now(),
        ]);

        event(new LicenseIssued($license));
        return $license;
    }
}
```

- [ ] **Step 3: Test**

Create `tests/Feature/License/LicenseIssuerTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Enums\{LicenseStatus, LicenseTier};
use App\Domain\License\Events\LicenseIssued;
use App\Domain\License\Models\License;
use App\Domain\License\Services\LicenseIssuer;
use App\Domain\Order\Models\Order;
use App\Domain\Order\Services\OrderService;
use Illuminate\Support\Facades\Event;

test('order paid olunca lisans üretilir', function () {
    Event::fake();
    $customer = Customer::factory()->create();
    $order = app(OrderService::class)->createPending($customer, LicenseTier::Pro);
    $order->update(['status' => 'paid', 'paid_at' => now()]);

    $license = app(LicenseIssuer::class)->issueFromOrder($order->fresh());

    expect($license->customer_id)->toBe($customer->id);
    expect($license->tier)->toBe(LicenseTier::Pro);
    expect($license->status)->toBe(LicenseStatus::Active);
    expect($license->account_limit)->toBe(100);
    expect($license->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(364);
    expect($order->fresh()->license_id)->toBe($license->id);

    Event::assertDispatched(LicenseIssued::class);
});

test('manuel lisans 30 günlük üretilebilir', function () {
    $customer = Customer::factory()->create();
    $license = app(LicenseIssuer::class)->issueManual($customer->id, LicenseTier::Trial, 30);
    expect($license->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(29);
});
```

- [ ] **Step 4: Run + commit**

```bash
./vendor/bin/pest tests/Feature/License/LicenseIssuerTest.php
git add app/Domain/License/Services/LicenseIssuer.php app/Domain/License/Events/LicenseIssued.php tests/
git commit -m "feat(license): LicenseIssuer — orderdan veya manual lisans üretimi + audit log + event"
```

---

### Task 12: Invoice Generation (Stub)

**Files:**
- Create: `app/Domain/Billing/Models/Invoice.php`
- Create: `app/Domain/Billing/Services/InvoiceGenerator.php`
- Create: `app/Jobs/GenerateInvoiceJob.php`
- Create: `app/Listeners/QueueInvoiceGeneration.php`
- Modify: `app/Providers/EventServiceProvider.php`
- Create: `tests/Feature/InvoiceTest.php`

- [ ] **Step 1: Invoice model**

Create `app/Domain/Billing/Models/Invoice.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\Billing\Models;
use App\Domain\Order\Models\Order;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Invoice extends Model {
    protected $fillable = ['order_id','number','parasut_id','pdf_path','sent_at','total_cents','tax_cents'];
    protected function casts(): array { return ['sent_at' => 'datetime']; }
    public function order(): BelongsTo { return $this->belongsTo(Order::class); }
}
```

- [ ] **Step 2: InvoiceGenerator (stub — gerçek Paraşüt L5'te)**

Create `app/Domain/Billing/Services/InvoiceGenerator.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\Billing\Services;

use App\Domain\Billing\Models\Invoice;
use App\Domain\Order\Models\Order;
use Illuminate\Support\Facades\Storage;

class InvoiceGenerator {
    public function generate(Order $order): Invoice {
        $year = now()->year;
        $count = Invoice::whereYear('created_at', $year)->count() + 1;
        $number = sprintf('ONX%d%06d', $year, $count);

        // L1 stub: gerçek PDF L5'te (DomPDF veya Snappy ile)
        $pdfPath = "invoices/{$year}/{$number}.txt";
        Storage::disk('local')->put($pdfPath, "Onoxsoft Fatura — Order #{$order->id} — ₺".number_format($order->amount_cents/100, 2));

        return Invoice::create([
            'order_id'    => $order->id,
            'number'      => $number,
            'pdf_path'    => $pdfPath,
            'total_cents' => $order->amount_cents,
            'tax_cents'   => (int) round($order->amount_cents * 0.20 / 1.20),  // %20 KDV (içeren)
        ]);
    }
}
```

- [ ] **Step 3: GenerateInvoiceJob + Listener**

Create `app/Jobs/GenerateInvoiceJob.php`:
```php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Domain\Billing\Services\InvoiceGenerator;
use App\Domain\License\Events\LicenseIssued;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class GenerateInvoiceJob implements ShouldQueue {
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    public function __construct(public int $orderId) {}
    public function handle(InvoiceGenerator $gen): void {
        $order = \App\Domain\Order\Models\Order::find($this->orderId);
        if (!$order || \App\Domain\Billing\Models\Invoice::where('order_id', $this->orderId)->exists()) return;
        $gen->generate($order);
    }
}
```

Create `app/Listeners/QueueInvoiceGeneration.php`:
```php
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Domain\License\Events\LicenseIssued;
use App\Jobs\GenerateInvoiceJob;

class QueueInvoiceGeneration {
    public function handle(LicenseIssued $event): void {
        if ($order = \App\Domain\Order\Models\Order::where('license_id', $event->license->id)->first()) {
            GenerateInvoiceJob::dispatch($order->id);
        }
    }
}
```

- [ ] **Step 4: EventServiceProvider listener bağla**

Modify `app/Providers/EventServiceProvider.php`:
```php
protected $listen = [
    \App\Domain\License\Events\LicenseIssued::class => [
        \App\Listeners\QueueInvoiceGeneration::class,
        \App\Listeners\SendLicenseConfirmationEmail::class,  // Task 13
    ],
];
```

- [ ] **Step 5: Test**

Create `tests/Feature/InvoiceTest.php`:
```php
<?php
use App\Domain\Billing\Models\Invoice;
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Enums\LicenseTier;
use App\Domain\License\Services\LicenseIssuer;
use App\Domain\Order\Models\Order;
use App\Domain\Order\Services\OrderService;
use App\Jobs\GenerateInvoiceJob;

test('fatura order için üretilir', function () {
    $c = Customer::factory()->create();
    $order = app(OrderService::class)->createPending($c, LicenseTier::Pro);
    $order->update(['status' => 'paid', 'paid_at' => now()]);
    app(LicenseIssuer::class)->issueFromOrder($order);

    // job sync run
    GenerateInvoiceJob::dispatchSync($order->id);

    $invoice = Invoice::where('order_id', $order->id)->first();
    expect($invoice)->not->toBeNull();
    expect($invoice->number)->toStartWith('ONX'.now()->year);
    expect($invoice->total_cents)->toBe(LicenseTier::Pro->priceCents());
    expect($invoice->tax_cents)->toBeGreaterThan(0);
});

test('aynı order için tekrar fatura üretilmez', function () {
    $c = Customer::factory()->create();
    $order = app(OrderService::class)->createPending($c, LicenseTier::Pro);
    $order->update(['status' => 'paid', 'paid_at' => now()]);
    app(LicenseIssuer::class)->issueFromOrder($order);

    GenerateInvoiceJob::dispatchSync($order->id);
    GenerateInvoiceJob::dispatchSync($order->id);

    expect(Invoice::where('order_id', $order->id)->count())->toBe(1);
});
```

- [ ] **Step 6: Run + commit**

```bash
./vendor/bin/pest tests/Feature/InvoiceTest.php
git add app/Domain/Billing/ app/Jobs/GenerateInvoiceJob.php app/Listeners/ app/Providers/EventServiceProvider.php tests/
git commit -m "feat(billing): InvoiceGenerator stub + GenerateInvoiceJob + LicenseIssued listener"
```

---

### Task 13: Email Notifications (LicenseConfirmation)

**Files:**
- Create: `app/Mail/LicenseActivatedMail.php`
- Create: `app/Listeners/SendLicenseConfirmationEmail.php`
- Create: `resources/views/emails/license-activated.blade.php`
- Create: `tests/Feature/EmailTest.php`

- [ ] **Step 1: Mailable**

Create `app/Mail/LicenseActivatedMail.php`:
```php
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Domain\License\Models\License;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\{Content, Envelope};

class LicenseActivatedMail extends Mailable implements ShouldQueue {
    use Queueable;
    public function __construct(public License $license) {}
    public function envelope(): Envelope {
        return new Envelope(subject: 'Onoxsoft Panel — Lisansınız Aktif');
    }
    public function content(): Content {
        return new Content(view: 'emails.license-activated', with: ['license' => $this->license]);
    }
}
```

- [ ] **Step 2: Listener**

Create `app/Listeners/SendLicenseConfirmationEmail.php`:
```php
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Domain\License\Events\LicenseIssued;
use App\Mail\LicenseActivatedMail;
use Illuminate\Support\Facades\Mail;

class SendLicenseConfirmationEmail {
    public function handle(LicenseIssued $event): void {
        Mail::to($event->license->customer->email)->queue(new LicenseActivatedMail($event->license));
    }
}
```

- [ ] **Step 3: Email şablonu**

Create `resources/views/emails/license-activated.blade.php`:
```blade
<!DOCTYPE html>
<html>
<body style="font-family:system-ui;max-width:600px;margin:40px auto;color:#1e293b">
    <div style="text-align:center;padding:20px;border-bottom:2px solid #6366f1">
        <h1>Onoxsoft Panel</h1>
    </div>
    <h2>Merhaba {{ $license->customer->name }},</h2>
    <p>Lisansınız başarıyla aktive edildi! 🎉</p>
    <div style="background:#f1f5f9;padding:20px;border-radius:8px;margin:20px 0">
        <p><strong>Paket:</strong> {{ ucfirst($license->tier->value) }}</p>
        <p><strong>Hesap limiti:</strong> {{ $license->account_limit ?: 'Sınırsız' }}</p>
        <p><strong>Aktivasyon:</strong> {{ $license->purchased_at->format('d.m.Y H:i') }}</p>
        <p><strong>Geçerli son:</strong> {{ $license->expires_at->format('d.m.Y') }}</p>
        <p><strong>Lisans ID:</strong> <code>{{ $license->uuid }}</code></p>
    </div>
    <p>Panel sunucunuz <strong>30 saniye içinde</strong> otomatik olarak aktive olacak. Sorun yaşarsanız:</p>
    <ul>
        <li>E-posta: <a href="mailto:destek@onoxsoft.com">destek@onoxsoft.com</a></li>
        <li>Portal: <a href="https://onox.com.tr/portal">onox.com.tr/portal</a></li>
    </ul>
    <p style="color:#64748b;font-size:12px;margin-top:40px">© ONOXSOFT Bilişim Ltd. Şti.</p>
</body>
</html>
```

- [ ] **Step 4: Test**

Create `tests/Feature/EmailTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Enums\LicenseTier;
use App\Domain\License\Services\LicenseIssuer;
use App\Domain\Order\Models\Order;
use App\Domain\Order\Services\OrderService;
use App\Mail\LicenseActivatedMail;
use Illuminate\Support\Facades\Mail;

test('lisans aktive olunca e-posta queue olur', function () {
    Mail::fake();
    $c = Customer::factory()->create();
    $order = app(OrderService::class)->createPending($c, LicenseTier::Pro);
    $order->update(['status' => 'paid', 'paid_at' => now()]);
    app(LicenseIssuer::class)->issueFromOrder($order);

    Mail::assertQueued(LicenseActivatedMail::class, fn($mail) => $mail->hasTo($c->email));
});
```

- [ ] **Step 5: Run + commit**

```bash
./vendor/bin/pest tests/Feature/EmailTest.php
git add app/Mail/ app/Listeners/SendLicenseConfirmationEmail.php resources/views/emails/ tests/
git commit -m "feat(email): LicenseActivatedMail + queued listener + blade şablon"
```

---

## Phase 5 — Verification API (Tasks 14-17)

### Task 14: API Foundation — Routes, Middleware, Request Signature

**Files:**
- Create: `routes/api.php` (yeni)
- Create: `app/Http/Middleware/VerifyApiSignature.php`
- Modify: `bootstrap/app.php` (api routing)
- Create: `app/Domain/Verify/Services/RequestSigner.php`

- [ ] **Step 1: bootstrap/app.php — API routes enable**

Modify `bootstrap/app.php` `withRouting()` çağrısı:
```php
->withRouting(
    web: __DIR__.'/../routes/web.php',
    api: __DIR__.'/../routes/api.php',
    commands: __DIR__.'/../routes/console.php',
    health: '/up',
)
```

- [ ] **Step 2: routes/api.php — v1 scaffold**

Create `routes/api.php`:
```php
<?php
use App\Http\Controllers\Api\V1\{ActivateController, HeartbeatController, TransferIpController, CheckController, CrackReportController};
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->name('api.v1.')->group(function () {
    // Public — intent token + new activation
    Route::post('/activate',  [ActivateController::class, 'store'])->name('activate');
    Route::get('/license/check', [CheckController::class, 'show'])->name('license.check');

    // Authenticated by license signature (custom middleware)
    Route::middleware(\App\Http\Middleware\VerifyApiSignature::class)->group(function () {
        Route::post('/heartbeat',     [HeartbeatController::class, 'store'])->name('heartbeat');
        Route::post('/transfer-ip',   [TransferIpController::class, 'store'])->name('transfer-ip');
        Route::post('/crack-report',  [CrackReportController::class, 'store'])->name('crack-report');
    });
});

// Rate limit: hepsi 60/dakika IP başına (config/api.php yerine inline)
Route::middleware('throttle:60,1')->any('/api/v1/{any}', fn() => abort(404))->where('any', '.*');
```

- [ ] **Step 3: VerifyApiSignature middleware**

Create `app/Http/Middleware/VerifyApiSignature.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Domain\License\Models\License;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyApiSignature {
    public function handle(Request $request, Closure $next): Response {
        $uuid = $request->header('X-License-UUID');
        $signature = $request->header('X-License-Signature');

        if (!$uuid || !$signature) {
            return response()->json(['error' => 'missing_signature'], 401);
        }

        $license = License::where('uuid', $uuid)->first();
        if (!$license) {
            return response()->json(['error' => 'license_not_found'], 401);
        }

        // İmza: HMAC-SHA256(method + path + body, license.uuid)
        $payload = $request->method().'|'.$request->path().'|'.($request->getContent() ?: '');
        $expected = hash_hmac('sha256', $payload, $license->uuid);

        if (!hash_equals($expected, $signature)) {
            return response()->json(['error' => 'invalid_signature'], 401);
        }

        $request->attributes->set('license', $license);
        return $next($request);
    }
}
```

(Not: gerçek production'da license-bazlı per-install secret kullanılır. MVP için uuid yeterli — pre-revealed UUID değil çünkü panel onu sadece activate'ten sonra alır.)

- [ ] **Step 4: Commit**

```bash
git add routes/api.php app/Http/Middleware/VerifyApiSignature.php bootstrap/app.php
git commit -m "feat(api): v1 router + VerifyApiSignature middleware + rate limit"
```

---

### Task 15: ActivateAction + ActivateController

**Files:**
- Create: `app/Http/Controllers/Api/V1/ActivateController.php`
- Create: `app/Domain/Verify/Actions/ActivateAction.php`
- Create: `tests/Feature/Api/V1/ActivateTest.php`

- [ ] **Step 1: ActivateAction**

Create `app/Domain/Verify/Actions/ActivateAction.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\Verify\Actions;

use App\Domain\License\Enums\BindingStatus;
use App\Domain\License\Models\{License, LicenseAudit, LicenseBinding, PurchaseIntent};
use App\Domain\License\Services\JwtSigner;
use Illuminate\Support\Facades\DB;

class ActivateAction {
    public function __construct(private JwtSigner $signer) {}

    public function execute(array $data): array {
        return DB::transaction(function () use ($data) {
            $intent = PurchaseIntent::where('token', $data['intent_token'])->first();
            if (!$intent) return ['error' => 'intent_not_found'];
            if ($intent->isExpired()) return ['error' => 'intent_expired'];

            // Intent'a bağlanmış order var mı?
            $order = $intent->order;
            if (!$order || $order->status !== 'paid') {
                return ['error' => 'order_not_paid', 'order_status' => $order?->status];
            }

            $license = $order->license;
            if (!$license) return ['error' => 'license_not_yet_issued'];

            // Binding eşleşme — IP+machine_id panel'in gönderdiğiyle aynı mı?
            if ($data['ip'] !== $intent->panel_ip || $data['machine_id'] !== $intent->panel_machine_id) {
                return ['error' => 'binding_mismatch'];
            }

            // Eski binding'leri stale yap, yeni active binding
            LicenseBinding::where('license_id', $license->id)
                ->where('status', BindingStatus::Active)
                ->update(['status' => BindingStatus::Stale]);

            LicenseBinding::create([
                'license_id'       => $license->id,
                'bound_ip'         => $data['ip'],
                'bound_machine_id' => $data['machine_id'],
                'bound_hostname'   => $data['hostname'] ?? null,
                'panel_version'    => $data['panel_version'] ?? null,
                'first_seen_at'    => now(),
                'last_seen_at'     => now(),
                'status'           => BindingStatus::Active,
            ]);

            $intent->update(['consumed_at' => now()]);

            LicenseAudit::create([
                'license_id' => $license->id,
                'event'      => 'activate',
                'payload'    => ['ip' => $data['ip'], 'machine_id' => $data['machine_id']],
                'ip'         => $data['ip'],
                'created_at' => now(),
            ]);

            return ['jwt' => $this->buildJwt($license, $data)];
        });
    }

    private function buildJwt(License $license, array $bindingData): string {
        $ttl = config('onoxsoft.license.jwt_ttl_hours', 25) * 3600;
        return $this->signer->sign([
            'iss' => 'onox.com.tr',
            'sub' => $license->uuid,
            'aud' => 'onoxsoft-panel',
            'iat' => time(),
            'exp' => time() + $ttl,
            'license' => [
                'tier'             => $license->tier->value,
                'account_limit'    => $license->account_limit,
                'features'         => $license->features,
                'bound_ip'         => $bindingData['ip'],
                'bound_machine_id' => $bindingData['machine_id'],
                'bound_hostname'   => $bindingData['hostname'] ?? null,
                'expires_at'       => $license->expires_at->toIso8601String(),
                'customer_email'   => $license->customer->email,
            ],
        ]);
    }
}
```

- [ ] **Step 2: ActivateController**

Create `app/Http/Controllers/Api/V1/ActivateController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Domain\Verify\Actions\ActivateAction;
use App\Http\Controllers\Controller;
use Illuminate\Http\{JsonResponse, Request};

class ActivateController extends Controller {
    public function store(Request $request, ActivateAction $action): JsonResponse {
        $data = $request->validate([
            'intent_token'  => 'required|string',
            'ip'            => 'required|ip',
            'machine_id'    => 'required|string|max:64',
            'hostname'      => 'nullable|string|max:253',
            'panel_version' => 'nullable|string|max:20',
        ]);

        $result = $action->execute($data);
        if (isset($result['error'])) {
            return response()->json($result, 422);
        }
        return response()->json([
            'license_jwt' => $result['jwt'],
            'expires_at'  => now()->addHours(25)->toIso8601String(),
        ], 200);
    }
}
```

- [ ] **Step 3: Test**

Create `tests/Feature/Api/V1/ActivateTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Enums\LicenseTier;
use App\Domain\License\Models\PurchaseIntent;
use App\Domain\License\Services\{JwtSigner, LicenseIssuer};
use App\Domain\Order\Services\OrderService;

test('intent + paid order ile activate başarılı', function () {
    $c = Customer::factory()->create();
    $intent = PurchaseIntent::create([
        'token' => 'tok123',
        'panel_ip' => '1.2.3.4',
        'panel_machine_id' => 'mid456',
        'panel_hostname' => 'host.example.com',
        'expires_at' => now()->addHour(),
    ]);
    $order = app(OrderService::class)->createPending($c, LicenseTier::Pro);
    $order->update(['status' => 'paid', 'paid_at' => now(), 'intent_token' => 'tok123']);
    $intent->update(['order_id' => $order->id]);
    app(LicenseIssuer::class)->issueFromOrder($order->fresh());

    $res = $this->postJson('/api/v1/activate', [
        'intent_token' => 'tok123',
        'ip'           => '1.2.3.4',
        'machine_id'   => 'mid456',
        'hostname'     => 'host.example.com',
        'panel_version'=> '1.0.0',
    ]);

    $res->assertOk()->assertJsonStructure(['license_jwt','expires_at']);

    $jwt = $res->json('license_jwt');
    $claims = app(JwtSigner::class)->verify($jwt);
    expect($claims)->not->toBeNull();
    expect($claims['license']['tier'])->toBe('pro');
    expect($claims['license']['bound_ip'])->toBe('1.2.3.4');
});

test('IP mismatch → binding_mismatch', function () {
    $c = Customer::factory()->create();
    PurchaseIntent::create([
        'token' => 'tok2', 'panel_ip' => '1.1.1.1', 'panel_machine_id' => 'mid', 'expires_at' => now()->addHour(),
    ]);

    $this->postJson('/api/v1/activate', [
        'intent_token' => 'tok2',
        'ip'           => '9.9.9.9',  // farklı
        'machine_id'   => 'mid',
    ])->assertStatus(422)->assertJson(['error' => 'intent_not_found'])
      ->or->assertJson(['error' => 'binding_mismatch']);
});

test('expired intent reddedilir', function () {
    PurchaseIntent::create([
        'token' => 'tok3', 'panel_ip' => '1.1.1.1', 'panel_machine_id' => 'mid', 'expires_at' => now()->subHour(),
    ]);
    $this->postJson('/api/v1/activate', [
        'intent_token' => 'tok3', 'ip' => '1.1.1.1', 'machine_id' => 'mid',
    ])->assertStatus(422)->assertJson(['error' => 'intent_expired']);
});
```

- [ ] **Step 4: Run + commit**

```bash
./vendor/bin/pest tests/Feature/Api/V1/ActivateTest.php
git add app/Domain/Verify/Actions/ActivateAction.php app/Http/Controllers/Api/V1/ActivateController.php tests/
git commit -m "feat(api): POST /api/v1/activate — intent + license → signed JWT"
```

---

### Task 16: Heartbeat + TransferIp Endpoints

**Files:**
- Create: `app/Domain/Verify/Actions/HeartbeatAction.php`
- Create: `app/Domain/Verify/Actions/TransferIpAction.php`
- Create: `app/Http/Controllers/Api/V1/{HeartbeatController,TransferIpController}.php`
- Create: `tests/Feature/Api/V1/{HeartbeatTest,TransferIpTest}.php`

- [ ] **Step 1: HeartbeatAction**

Create `app/Domain/Verify/Actions/HeartbeatAction.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\Verify\Actions;

use App\Domain\License\Enums\{BindingStatus, LicenseStatus};
use App\Domain\License\Models\{License, LicenseAudit, LicenseBinding};
use App\Domain\License\Services\JwtSigner;

class HeartbeatAction {
    public function __construct(private JwtSigner $signer) {}

    public function execute(License $license, array $data): array {
        if ($license->status !== LicenseStatus::Active) {
            return ['error' => 'license_'.$license->status->value];
        }
        if ($license->isExpired()) {
            $license->update(['status' => LicenseStatus::Expired]);
            return ['error' => 'license_expired'];
        }

        $binding = LicenseBinding::where('license_id', $license->id)
            ->where('bound_ip', $data['ip'])
            ->where('bound_machine_id', $data['machine_id'])
            ->where('status', BindingStatus::Active)
            ->first();

        if (!$binding) {
            return ['error' => 'binding_not_found', 'hint' => 'transfer-ip ile yenile'];
        }

        $binding->update(['last_seen_at' => now(), 'panel_version' => $data['panel_version'] ?? $binding->panel_version]);
        $license->update(['last_heartbeat_at' => now()]);

        LicenseAudit::create([
            'license_id' => $license->id,
            'event'      => 'heartbeat',
            'payload'    => ['ip' => $data['ip']],
            'ip'         => $data['ip'],
            'created_at' => now(),
        ]);

        // Yeni JWT (refresh)
        $ttl = config('onoxsoft.license.jwt_ttl_hours', 25) * 3600;
        return ['jwt' => $this->signer->sign([
            'iss' => 'onox.com.tr',
            'sub' => $license->uuid,
            'aud' => 'onoxsoft-panel',
            'iat' => time(),
            'exp' => time() + $ttl,
            'license' => [
                'tier'             => $license->tier->value,
                'account_limit'    => $license->account_limit,
                'features'         => $license->features,
                'bound_ip'         => $data['ip'],
                'bound_machine_id' => $data['machine_id'],
                'expires_at'       => $license->expires_at->toIso8601String(),
                'customer_email'   => $license->customer->email,
            ],
        ])];
    }
}
```

- [ ] **Step 2: HeartbeatController**

Create `app/Http/Controllers/Api/V1/HeartbeatController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Domain\Verify\Actions\HeartbeatAction;
use App\Http\Controllers\Controller;
use Illuminate\Http\{JsonResponse, Request};

class HeartbeatController extends Controller {
    public function store(Request $request, HeartbeatAction $action): JsonResponse {
        $data = $request->validate([
            'ip'           => 'required|ip',
            'machine_id'   => 'required|string|max:64',
            'panel_version'=> 'nullable|string|max:20',
        ]);
        $license = $request->attributes->get('license');
        $result = $action->execute($license, $data);

        if (isset($result['error'])) return response()->json($result, 410);
        return response()->json(['license_jwt' => $result['jwt']]);
    }
}
```

- [ ] **Step 3: TransferIpAction + Controller**

Create `app/Domain/Verify/Actions/TransferIpAction.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\Verify\Actions;

use App\Domain\License\Enums\BindingStatus;
use App\Domain\License\Models\{License, LicenseAudit, LicenseBinding};
use App\Domain\License\Services\JwtSigner;
use Illuminate\Support\Facades\DB;

class TransferIpAction {
    public function __construct(private JwtSigner $signer) {}

    public function execute(License $license, array $data): array {
        return DB::transaction(function () use ($license, $data) {
            // Eski binding'leri stale
            LicenseBinding::where('license_id', $license->id)
                ->where('status', BindingStatus::Active)
                ->update(['status' => BindingStatus::Stale]);

            $new = LicenseBinding::create([
                'license_id'       => $license->id,
                'bound_ip'         => $data['new_ip'],
                'bound_machine_id' => $data['new_machine_id'],
                'bound_hostname'   => $data['new_hostname'] ?? null,
                'first_seen_at'    => now(),
                'last_seen_at'     => now(),
                'status'           => BindingStatus::Active,
            ]);

            LicenseAudit::create([
                'license_id' => $license->id,
                'event'      => 'ip_transfer',
                'payload'    => ['new_ip' => $data['new_ip'], 'new_machine_id' => $data['new_machine_id']],
                'ip'         => $data['new_ip'],
                'created_at' => now(),
            ]);

            $ttl = config('onoxsoft.license.jwt_ttl_hours', 25) * 3600;
            return ['jwt' => $this->signer->sign([
                'iss' => 'onox.com.tr', 'sub' => $license->uuid, 'aud' => 'onoxsoft-panel',
                'iat' => time(), 'exp' => time()+$ttl,
                'license' => [
                    'tier' => $license->tier->value, 'account_limit' => $license->account_limit,
                    'features' => $license->features, 'bound_ip' => $data['new_ip'],
                    'bound_machine_id' => $data['new_machine_id'],
                    'expires_at' => $license->expires_at->toIso8601String(),
                    'customer_email' => $license->customer->email,
                ],
            ])];
        });
    }
}
```

Create `app/Http/Controllers/Api/V1/TransferIpController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Domain\Verify\Actions\TransferIpAction;
use App\Http\Controllers\Controller;
use Illuminate\Http\{JsonResponse, Request};

class TransferIpController extends Controller {
    public function store(Request $request, TransferIpAction $action): JsonResponse {
        $data = $request->validate([
            'new_ip'         => 'required|ip',
            'new_machine_id' => 'required|string|max:64',
            'new_hostname'   => 'nullable|string|max:253',
        ]);
        $license = $request->attributes->get('license');
        $result = $action->execute($license, $data);
        return response()->json(['license_jwt' => $result['jwt']]);
    }
}
```

- [ ] **Step 4: Helper trait — istek imzalama (test için)**

Create `tests/Helpers/SignsApiRequests.php`:
```php
<?php
namespace Tests\Helpers;
trait SignsApiRequests {
    protected function signedHeaders(string $licenseUuid, string $method, string $path, array $body): array {
        $payload = strtoupper($method).'|'.ltrim($path, '/').'|'.json_encode($body);
        return [
            'X-License-UUID'      => $licenseUuid,
            'X-License-Signature' => hash_hmac('sha256', $payload, $licenseUuid),
            'Accept'              => 'application/json',
        ];
    }
}
```

Modify `tests/Pest.php` — `uses(SignsApiRequests::class)->in('Feature/Api')`.

- [ ] **Step 5: HeartbeatTest**

Create `tests/Feature/Api/V1/HeartbeatTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Models\{License, LicenseBinding};

test('aktif binding ile heartbeat yeni JWT döner', function () {
    $c = Customer::factory()->create();
    $license = License::factory()->create(['customer_id' => $c->id]);
    LicenseBinding::factory()->create([
        'license_id' => $license->id, 'bound_ip' => '1.2.3.4',
        'bound_machine_id' => 'mid', 'status' => 'active',
        'first_seen_at' => now()->subDay(), 'last_seen_at' => now()->subHour(),
    ]);

    $body = ['ip' => '1.2.3.4', 'machine_id' => 'mid'];
    $headers = $this->signedHeaders($license->uuid, 'POST', 'api/v1/heartbeat', $body);

    $this->postJson('/api/v1/heartbeat', $body, $headers)
        ->assertOk()
        ->assertJsonStructure(['license_jwt']);
});

test('binding eşleşmeyince 410 dönerb', function () {
    $c = Customer::factory()->create();
    $license = License::factory()->create(['customer_id' => $c->id]);

    $body = ['ip' => '9.9.9.9', 'machine_id' => 'wrong'];
    $headers = $this->signedHeaders($license->uuid, 'POST', 'api/v1/heartbeat', $body);

    $this->postJson('/api/v1/heartbeat', $body, $headers)
        ->assertStatus(410)
        ->assertJson(['error' => 'binding_not_found']);
});

test('imza hatalıysa 401', function () {
    $body = ['ip' => '1.2.3.4', 'machine_id' => 'mid'];
    $this->postJson('/api/v1/heartbeat', $body, [
        'X-License-UUID' => 'fake-uuid',
        'X-License-Signature' => 'fake-sig',
    ])->assertStatus(401);
});
```

- [ ] **Step 6: TransferIpTest**

Create `tests/Feature/Api/V1/TransferIpTest.php` — analog, eski binding'in stale olduğunu + yeni binding'in active olduğunu doğrula.

- [ ] **Step 7: Run + commit**

```bash
./vendor/bin/pest tests/Feature/Api/V1/
git add app/Domain/Verify/Actions/{HeartbeatAction,TransferIpAction}.php app/Http/Controllers/Api/V1/{HeartbeatController,TransferIpController}.php tests/
git commit -m "feat(api): POST /api/v1/heartbeat + /api/v1/transfer-ip endpoint'leri"
```

---

### Task 17: CheckController (Polling) + CrackReport

**Files:**
- Create: `app/Http/Controllers/Api/V1/CheckController.php`
- Create: `app/Http/Controllers/Api/V1/CrackReportController.php`
- Create: `app/Domain/Security/Models/CrackReport.php`
- Create: `tests/Feature/Api/V1/CheckTest.php`

- [ ] **Step 1: CrackReport modeli**

Create `app/Domain/Security/Models/CrackReport.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\Security\Models;
use App\Domain\License\Models\License;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class CrackReport extends Model {
    protected $fillable = ['license_id','signal','severity','payload','reported_ip','reviewed_at','reviewer_id','action_taken'];
    protected function casts(): array {
        return ['payload' => 'array', 'reviewed_at' => 'datetime'];
    }
    public function license(): BelongsTo { return $this->belongsTo(License::class); }
}
```

- [ ] **Step 2: CheckController (panel polling)**

Create `app/Http/Controllers/Api/V1/CheckController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Domain\License\Models\PurchaseIntent;
use App\Domain\Verify\Actions\ActivateAction;
use App\Http\Controllers\Controller;
use Illuminate\Http\{JsonResponse, Request};

class CheckController extends Controller {
    public function show(Request $request, ActivateAction $action): JsonResponse {
        $token = $request->query('intent');
        if (!$token) return response()->json(['status' => 'no_intent'], 404);

        $intent = PurchaseIntent::where('token', $token)->first();
        if (!$intent) return response()->json(['status' => 'not_found'], 404);

        $order = $intent->order;
        if (!$order)                              return response()->json(['status' => 'awaiting_order']);
        if ($order->status === 'pending')         return response()->json(['status' => 'awaiting_payment']);
        if ($order->status === 'failed')          return response()->json(['status' => 'payment_failed']);
        if ($order->status !== 'paid')            return response()->json(['status' => $order->status]);

        $license = $order->license;
        if (!$license) return response()->json(['status' => 'awaiting_issue']);

        // Eğer zaten consume edildiyse direkt JWT lazım — yine sign et
        if ($intent->consumed_at) {
            // Önceki binding'ten JWT üret
            $result = $action->execute([
                'intent_token' => $token,
                'ip'           => $intent->panel_ip,
                'machine_id'   => $intent->panel_machine_id,
                'hostname'     => $intent->panel_hostname,
            ]);
            if (isset($result['jwt'])) {
                return response()->json(['status' => 'active', 'license_jwt' => $result['jwt']]);
            }
            return response()->json(['status' => 'consumed_no_jwt']);
        }

        // İlk kez consume — Activate çağrısını panel kendisi POST'lar.
        // Bu endpoint sadece "hazır mı?" bilgisini döner.
        return response()->json(['status' => 'ready_to_activate']);
    }
}
```

- [ ] **Step 3: CrackReportController**

Create `app/Http/Controllers/Api/V1/CrackReportController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Domain\Security\Models\CrackReport;
use App\Http\Controllers\Controller;
use Illuminate\Http\{JsonResponse, Request};
use Illuminate\Support\Facades\Log;

class CrackReportController extends Controller {
    public function store(Request $request): JsonResponse {
        $data = $request->validate([
            'signal'    => 'required|in:tamper,tls_pin_fail,time_warp,honeypot_hit,dual_install,integrity_fail',
            'payload'   => 'array',
        ]);

        $license = $request->attributes->get('license');

        $severity = match ($data['signal']) {
            'tamper','tls_pin_fail','dual_install' => 'critical',
            'integrity_fail','honeypot_hit'        => 'high',
            'time_warp'                            => 'medium',
            default                                => 'low',
        };

        $report = CrackReport::create([
            'license_id'  => $license?->id,
            'signal'      => $data['signal'],
            'severity'    => $severity,
            'payload'     => $data['payload'] ?? [],
            'reported_ip' => $request->ip(),
        ]);

        // Critical → license suspend
        if ($severity === 'critical' && $license) {
            $license->update(['status' => 'suspended']);
            Log::critical('License suspended due to crack signal', [
                'license_uuid' => $license->uuid,
                'signal'       => $data['signal'],
                'report_id'    => $report->id,
            ]);
        }

        return response()->json(['recorded' => true, 'report_id' => $report->id]);
    }
}
```

- [ ] **Step 4: Test**

Create `tests/Feature/Api/V1/CheckTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Enums\LicenseTier;
use App\Domain\License\Models\PurchaseIntent;
use App\Domain\License\Services\LicenseIssuer;
use App\Domain\Order\Services\OrderService;

test('intent yokken 404', function () {
    $this->getJson('/api/v1/license/check?intent=missing')
        ->assertStatus(404)->assertJson(['status' => 'not_found']);
});

test('order pending iken awaiting_payment döner', function () {
    $c = Customer::factory()->create();
    $intent = PurchaseIntent::create([
        'token' => 't', 'panel_ip' => '1', 'panel_machine_id' => 'm', 'expires_at' => now()->addHour(),
    ]);
    $order = app(OrderService::class)->createPending($c, LicenseTier::Pro);
    $intent->update(['order_id' => $order->id]);

    $this->getJson('/api/v1/license/check?intent=t')
        ->assertOk()->assertJson(['status' => 'awaiting_payment']);
});

test('order paid + license issued → ready_to_activate', function () {
    $c = Customer::factory()->create();
    $intent = PurchaseIntent::create([
        'token' => 't2', 'panel_ip' => '1.2.3.4', 'panel_machine_id' => 'm', 'expires_at' => now()->addHour(),
    ]);
    $order = app(OrderService::class)->createPending($c, LicenseTier::Pro);
    $order->update(['status' => 'paid', 'paid_at' => now()]);
    $intent->update(['order_id' => $order->id]);
    app(LicenseIssuer::class)->issueFromOrder($order->fresh());

    $this->getJson('/api/v1/license/check?intent=t2')
        ->assertOk()->assertJson(['status' => 'ready_to_activate']);
});
```

- [ ] **Step 5: CrackReport test**

```php
test('critical crack signal license suspend eder', function () {
    $c = \App\Domain\Customer\Models\Customer::factory()->create();
    $license = \App\Domain\License\Models\License::factory()->create(['customer_id' => $c->id, 'status' => 'active']);

    $body = ['signal' => 'tamper', 'payload' => ['detail' => 'x']];
    $headers = $this->signedHeaders($license->uuid, 'POST', 'api/v1/crack-report', $body);

    $this->postJson('/api/v1/crack-report', $body, $headers)->assertOk();

    expect($license->fresh()->status->value)->toBe('suspended');
    expect(\App\Domain\Security\Models\CrackReport::count())->toBe(1);
});
```

- [ ] **Step 6: Run + commit**

```bash
./vendor/bin/pest tests/Feature/Api/V1/
git add app/Domain/Security/ app/Http/Controllers/Api/V1/{CheckController,CrackReportController}.php tests/
git commit -m "feat(api): GET /api/v1/license/check polling + POST /api/v1/crack-report"
```

---

## Phase 6 — Admin Panel (Tasks 18-20)

### Task 18: Admin Auth + Layout + Dashboard

**Files:**
- Create: `app/Http/Middleware/EnsureIsAdmin.php`
- Create: `app/Http/Controllers/Admin/DashboardController.php`
- Create: `resources/js/Layouts/AdminLayout.vue`
- Create: `resources/js/Pages/Admin/Dashboard.vue`
- Create: `database/seeders/AdminSeeder.php`
- Modify: `routes/web.php`
- Create: `tests/Feature/Admin/DashboardTest.php`

- [ ] **Step 1: Spatie Permission setup**

Run:
```bash
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider" --tag="migrations"
# permission migration table customers'a göre uyarlanmalı — guard_name = 'web', model_type = Customer::class
php artisan migrate
```

Update `app/Domain/Customer/Models/Customer.php`:
```php
use Spatie\Permission\Traits\HasRoles;
class Customer extends Authenticatable {
    use HasFactory, Notifiable, TwoFactorAuthenticatable, HasRoles;
    // ...
}
```

- [ ] **Step 2: AdminSeeder**

Create `database/seeders/AdminSeeder.php`:
```php
<?php
namespace Database\Seeders;
use App\Domain\Customer\Models\Customer;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;

class AdminSeeder extends Seeder {
    public function run(): void {
        Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
        Role::firstOrCreate(['name' => 'customer', 'guard_name' => 'web']);

        $admin = Customer::firstOrCreate(
            ['email' => 'admin@onoxsoft.com'],
            [
                'name' => 'Onoxsoft Admin',
                'password' => bcrypt('admin1234'),
                'email_verified_at' => now(),
                'kvkk_consented_at' => now(),
            ]
        );
        $admin->assignRole('admin');
    }
}
```

Run: `php artisan db:seed --class=AdminSeeder`.

- [ ] **Step 3: EnsureIsAdmin middleware**

Create `app/Http/Middleware/EnsureIsAdmin.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureIsAdmin {
    public function handle(Request $request, Closure $next): Response {
        if (!$request->user() || !$request->user()->hasRole('admin')) {
            abort(403, 'Admin yetkisi gerekli');
        }
        return $next($request);
    }
}
```

Register in `bootstrap/app.php` `withMiddleware()`:
```php
$middleware->alias(['admin' => \App\Http\Middleware\EnsureIsAdmin::class]);
```

- [ ] **Step 4: Admin routes**

Modify `routes/web.php`:
```php
Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
    Route::get('/', [\App\Http\Controllers\Admin\DashboardController::class, 'index'])->name('dashboard');
    Route::resource('customers',     \App\Http\Controllers\Admin\CustomerController::class);
    Route::resource('licenses',      \App\Http\Controllers\Admin\LicenseController::class);
    Route::resource('orders',        \App\Http\Controllers\Admin\OrderController::class)->only(['index','show']);
    Route::resource('crack-reports', \App\Http\Controllers\Admin\CrackReportController::class)->only(['index','show','update']);
});
```

- [ ] **Step 5: DashboardController + Vue page**

Create `app/Http/Controllers/Admin/DashboardController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Domain\Billing\Models\Invoice;
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Models\License;
use App\Domain\Order\Models\Order;
use App\Domain\Security\Models\CrackReport;
use App\Http\Controllers\Controller;
use Inertia\{Inertia, Response};

class DashboardController extends Controller {
    public function index(): Response {
        return Inertia::render('Admin/Dashboard', [
            'stats' => [
                'customers'        => Customer::count(),
                'active_licenses'  => License::where('status', 'active')->count(),
                'mrr_cents'        => Order::where('status', 'paid')->whereMonth('paid_at', now()->month)->sum('amount_cents'),
                'pending_orders'   => Order::where('status', 'pending')->count(),
                'crack_alerts'     => CrackReport::whereNull('reviewed_at')->count(),
                'invoices_month'   => Invoice::whereMonth('created_at', now()->month)->count(),
            ],
            'recent_orders' => Order::with('customer','license')
                ->where('status', 'paid')->latest('paid_at')->limit(10)->get()
                ->map(fn($o) => [
                    'id' => $o->id, 'customer' => $o->customer->email,
                    'tier' => $o->payload['tier'] ?? '—',
                    'amount' => $o->amount_cents/100, 'paid_at' => $o->paid_at?->diffForHumans(),
                ]),
        ]);
    }
}
```

Create `resources/js/Pages/Admin/Dashboard.vue` — stat kartlar grid + recent orders tablosu.

- [ ] **Step 6: AdminLayout.vue**

Create `resources/js/Layouts/AdminLayout.vue` — sidebar (Dashboard / Müşteriler / Lisanslar / Siparişler / Crack Raporları) + topbar (logout). PANELTR'deki AdminLayout pattern'ini sadeleştir.

- [ ] **Step 7: Test**

Create `tests/Feature/Admin/DashboardTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;
use Database\Seeders\AdminSeeder;

test('admin dashboard görebilir', function () {
    $this->seed(AdminSeeder::class);
    $admin = Customer::where('email','admin@onoxsoft.com')->first();
    $this->actingAs($admin)->get('/admin')->assertOk()
        ->assertInertia(fn($p) => $p->component('Admin/Dashboard')->has('stats'));
});

test('admin olmayan 403 alır', function () {
    $c = Customer::factory()->create();
    $this->actingAs($c)->get('/admin')->assertForbidden();
});

test('guest /admin'e gidemez', function () {
    $this->get('/admin')->assertRedirect('/login');
});
```

- [ ] **Step 8: Run + commit**

```bash
./vendor/bin/pest tests/Feature/Admin/DashboardTest.php
git add app/ resources/js/Layouts/AdminLayout.vue resources/js/Pages/Admin/Dashboard.vue database/seeders/ routes/web.php tests/
git commit -m "feat(admin): Spatie role + EnsureIsAdmin middleware + dashboard + seed"
```

---

### Task 19: Admin CRUD — Customers & Licenses

**Files:**
- Create: `app/Http/Controllers/Admin/CustomerController.php`
- Create: `app/Http/Controllers/Admin/LicenseController.php`
- Create: `resources/js/Pages/Admin/{Customers,Licenses}/{Index,Show,Edit}.vue`
- Create: `tests/Feature/Admin/{CustomerCrudTest,LicenseCrudTest}.php`

- [ ] **Step 1: CustomerController**

Create `app/Http/Controllers/Admin/CustomerController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Domain\Customer\Models\Customer;
use App\Http\Controllers\Controller;
use Illuminate\Http\{RedirectResponse, Request};
use Inertia\{Inertia, Response};

class CustomerController extends Controller {
    public function index(Request $request): Response {
        $q = Customer::query();
        if ($s = $request->string('q')->trim()->value()) {
            $q->where(fn($w) => $w->where('email','like',"%{$s}%")->orWhere('name','like',"%{$s}%")->orWhere('company','like',"%{$s}%"));
        }
        return Inertia::render('Admin/Customers/Index', [
            'customers' => $q->withCount('licenses')->latest()->paginate(20)->withQueryString(),
            'filters'   => ['q' => $s ?? ''],
        ]);
    }

    public function show(Customer $customer): Response {
        return Inertia::render('Admin/Customers/Show', [
            'customer' => $customer->load(['licenses.bindings', 'licenses.audits' => fn($q) => $q->latest()->limit(50)]),
        ]);
    }

    public function update(Request $request, Customer $customer): RedirectResponse {
        $data = $request->validate([
            'name' => 'sometimes|required|max:255',
            'company' => 'nullable|max:255',
            'tax_number' => 'nullable|max:20',
            'phone' => 'nullable|max:20',
        ]);
        $customer->update($data);
        return back()->with('success', 'Müşteri güncellendi');
    }

    public function destroy(Customer $customer): RedirectResponse {
        $customer->delete();
        return redirect()->route('admin.customers.index')->with('success', 'Müşteri silindi');
    }
}
```

(Customer modeline `licenses(): HasMany` ilişkisi ekle.)

- [ ] **Step 2: LicenseController — listing + manuel issue**

Create `app/Http/Controllers/Admin/LicenseController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Enums\{LicenseStatus, LicenseTier};
use App\Domain\License\Models\License;
use App\Domain\License\Services\LicenseIssuer;
use App\Http\Controllers\Controller;
use Illuminate\Http\{RedirectResponse, Request};
use Inertia\{Inertia, Response};

class LicenseController extends Controller {
    public function index(Request $request): Response {
        $q = License::query()->with('customer', 'bindings');
        if ($status = $request->string('status')->value()) $q->where('status', $status);
        if ($tier = $request->string('tier')->value())     $q->where('tier', $tier);

        return Inertia::render('Admin/Licenses/Index', [
            'licenses' => $q->latest('purchased_at')->paginate(20)->withQueryString()
                ->through(fn($l) => [
                    'id'         => $l->id,
                    'uuid'       => $l->uuid,
                    'customer'   => $l->customer->email,
                    'tier'       => $l->tier->value,
                    'status'     => $l->status->value,
                    'limit'      => $l->account_limit,
                    'expires_at' => $l->expires_at->format('d.m.Y'),
                    'binding'    => optional($l->activeBinding())->bound_ip,
                ]),
            'filters' => $request->only('status','tier'),
        ]);
    }

    public function create(): Response {
        return Inertia::render('Admin/Licenses/Create', [
            'customers' => Customer::orderBy('email')->get(['id','email','name']),
            'tiers'     => collect(LicenseTier::cases())->map(fn($t) => ['value' => $t->value, 'label' => $t->label()]),
        ]);
    }

    public function store(Request $request, LicenseIssuer $issuer): RedirectResponse {
        $data = $request->validate([
            'customer_id'   => 'required|exists:customers,id',
            'tier'          => 'required|in:trial,lite,pro,unlimited,enterprise',
            'duration_days' => 'required|integer|min:1|max:3650',
        ]);
        $license = $issuer->issueManual($data['customer_id'], LicenseTier::from($data['tier']), $data['duration_days']);
        return redirect()->route('admin.licenses.show', $license)->with('success', 'Lisans üretildi');
    }

    public function show(License $license): Response {
        return Inertia::render('Admin/Licenses/Show', [
            'license' => $license->load('customer', 'bindings', 'audits'),
        ]);
    }

    public function update(Request $request, License $license): RedirectResponse {
        $data = $request->validate([
            'status'        => 'sometimes|in:active,suspended,expired,revoked',
            'revoked_reason'=> 'nullable|max:500',
        ]);
        if (($data['status'] ?? null) === 'revoked' && !$license->revoked_at) {
            $data['revoked_at'] = now();
        }
        $license->update($data);
        return back()->with('success', 'Lisans güncellendi');
    }
}
```

- [ ] **Step 3: Vue sayfaları — basit liste + form**

Customer için Index (search + paginated table), Show (lisans listesi), Edit (basit form).
License için Index (filter by status/tier), Create (customer select + tier + duration), Show (binding + audit log).

(Bu sayfalar PANELTR'deki Admin/Accounts pattern'inin sadeleştirilmiş hali. Toplam ~600 satır Vue.)

- [ ] **Step 4: Test**

Create `tests/Feature/Admin/CustomerCrudTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;
use Database\Seeders\AdminSeeder;

beforeEach(function () {
    $this->seed(AdminSeeder::class);
    $this->admin = Customer::where('email','admin@onoxsoft.com')->first();
});

test('admin müşteri listesi görür', function () {
    Customer::factory()->count(5)->create();
    $this->actingAs($this->admin)->get('/admin/customers')
        ->assertOk()->assertInertia(fn($p) => $p->has('customers.data', 6)); // 5 + admin
});

test('admin müşteri arar', function () {
    Customer::factory()->create(['email' => 'foo@bar.com']);
    Customer::factory()->create(['email' => 'baz@qux.com']);
    $this->actingAs($this->admin)->get('/admin/customers?q=foo')
        ->assertInertia(fn($p) => $p->has('customers.data', 1)->where('customers.data.0.email', 'foo@bar.com'));
});
```

Create `tests/Feature/Admin/LicenseCrudTest.php` — manuel issue test, status update test.

- [ ] **Step 5: Run + commit**

```bash
./vendor/bin/pest tests/Feature/Admin/
git add app/Http/Controllers/Admin/{CustomerController,LicenseController}.php resources/js/Pages/Admin/{Customers,Licenses}/ tests/
git commit -m "feat(admin): müşteri + lisans CRUD (liste, search, manuel issue, suspend/revoke)"
```

---

### Task 20: Admin — Orders + CrackReports

**Files:**
- Create: `app/Http/Controllers/Admin/{OrderController,CrackReportController}.php`
- Create: `resources/js/Pages/Admin/{Orders,CrackReports}/{Index,Show}.vue`
- Create: `tests/Feature/Admin/{OrderListTest,CrackReportTest}.php`

- [ ] **Step 1: OrderController (read-only)**

Create `app/Http/Controllers/Admin/OrderController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Domain\Order\Models\Order;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\{Inertia, Response};

class OrderController extends Controller {
    public function index(Request $request): Response {
        $q = Order::query()->with('customer','license');
        if ($s = $request->string('status')->value()) $q->where('status', $s);
        return Inertia::render('Admin/Orders/Index', [
            'orders' => $q->latest()->paginate(20)->withQueryString(),
            'filters' => $request->only('status'),
        ]);
    }
    public function show(Order $order): Response {
        return Inertia::render('Admin/Orders/Show', ['order' => $order->load('customer','license')]);
    }
}
```

- [ ] **Step 2: CrackReportController**

Create `app/Http/Controllers/Admin/CrackReportController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Domain\Security\Models\CrackReport;
use App\Http\Controllers\Controller;
use Illuminate\Http\{RedirectResponse, Request};
use Inertia\{Inertia, Response};

class CrackReportController extends Controller {
    public function index(Request $request): Response {
        $q = CrackReport::query()->with('license.customer');
        if ($request->boolean('unreviewed')) $q->whereNull('reviewed_at');
        return Inertia::render('Admin/CrackReports/Index', [
            'reports' => $q->latest()->paginate(20)->withQueryString(),
            'filters' => $request->only('unreviewed'),
        ]);
    }
    public function show(CrackReport $crack_report): Response {
        return Inertia::render('Admin/CrackReports/Show', ['report' => $crack_report->load('license.customer')]);
    }
    public function update(Request $request, CrackReport $crack_report): RedirectResponse {
        $data = $request->validate(['action_taken' => 'required|max:255']);
        $crack_report->update([
            'reviewed_at'  => now(),
            'reviewer_id'  => $request->user()->id,
            'action_taken' => $data['action_taken'],
        ]);
        return back()->with('success', 'Rapor incelendi');
    }
}
```

- [ ] **Step 3: Tests**

```php
test('admin order listesi paginate eder', function () {
    $this->seed(\Database\Seeders\AdminSeeder::class);
    $admin = \App\Domain\Customer\Models\Customer::where('email','admin@onoxsoft.com')->first();
    \App\Domain\Order\Models\Order::factory()->count(25)->create();
    $this->actingAs($admin)->get('/admin/orders')->assertOk()
        ->assertInertia(fn($p) => $p->has('orders.data', 20));
});

test('admin crack raporunu incelediği zaman timestamp atanır', function () {
    $this->seed(\Database\Seeders\AdminSeeder::class);
    $admin = \App\Domain\Customer\Models\Customer::where('email','admin@onoxsoft.com')->first();
    $r = \App\Domain\Security\Models\CrackReport::create([
        'signal' => 'tamper', 'severity' => 'critical', 'payload' => [],
    ]);
    $this->actingAs($admin)->put("/admin/crack-reports/{$r->id}", ['action_taken' => 'license suspended'])
        ->assertRedirect();
    expect($r->fresh()->reviewed_at)->not->toBeNull();
});
```

- [ ] **Step 4: Vue sayfaları + commit**

Index/Show'lar PANELTR Audit Index pattern'ine benzer (tablo + filtre). Show'larda `payload` JSON pretty-print.

```bash
./vendor/bin/pest tests/Feature/Admin/
git add app/Http/Controllers/Admin/{OrderController,CrackReportController}.php resources/js/Pages/Admin/{Orders,CrackReports}/ tests/
git commit -m "feat(admin): order liste + crack reports inceleme"
```

---

## Phase 7 — Customer Portal (Task 21)

### Task 21: Portal — Dashboard, Lisanslarım, Detay, IP Transfer

**Files:**
- Create: `resources/js/Layouts/PortalLayout.vue`
- Create: `app/Http/Controllers/Portal/{DashboardController,LicenseController,InvoiceController,SettingsController}.php`
- Create: `resources/js/Pages/Portal/{Dashboard,Licenses,LicenseDetail,Invoices,Settings}.vue`
- Modify: `routes/web.php`
- Create: `tests/Feature/Portal/PortalTest.php`

- [ ] **Step 1: Portal routes**

Modify `routes/web.php`:
```php
Route::middleware(['auth','verified'])->prefix('portal')->name('portal.')->group(function () {
    Route::get('/',          [\App\Http\Controllers\Portal\DashboardController::class, 'index'])->name('dashboard');
    Route::get('/lisanslar', [\App\Http\Controllers\Portal\LicenseController::class, 'index'])->name('licenses.index');
    Route::get('/lisanslar/{license}', [\App\Http\Controllers\Portal\LicenseController::class, 'show'])->name('licenses.show');
    Route::post('/lisanslar/{license}/ip-transfer', [\App\Http\Controllers\Portal\LicenseController::class, 'transferIp'])->name('licenses.transfer-ip');
    Route::get('/faturalar', [\App\Http\Controllers\Portal\InvoiceController::class, 'index'])->name('invoices.index');
    Route::get('/faturalar/{invoice}/pdf', [\App\Http\Controllers\Portal\InvoiceController::class, 'download'])->name('invoices.pdf');
    Route::get('/ayarlar',   [\App\Http\Controllers\Portal\SettingsController::class, 'edit'])->name('settings');
    Route::patch('/ayarlar', [\App\Http\Controllers\Portal\SettingsController::class, 'update'])->name('settings.update');
});
```

- [ ] **Step 2: DashboardController**

Create `app/Http/Controllers/Portal/DashboardController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\{Inertia, Response};

class DashboardController extends Controller {
    public function index(Request $request): Response {
        $user = $request->user();
        return Inertia::render('Portal/Dashboard', [
            'stats' => [
                'active_licenses' => $user->licenses()->where('status','active')->count(),
                'next_renewal'    => $user->licenses()->where('status','active')->min('expires_at'),
                'total_spent_cents' => $user->orders()->where('status','paid')->sum('amount_cents'),
            ],
            'licenses' => $user->licenses()->with('bindings')->get()->map(fn($l) => [
                'uuid'       => $l->uuid,
                'tier'       => $l->tier->value,
                'status'     => $l->status->value,
                'expires_at' => $l->expires_at->format('d.m.Y'),
                'binding_ip' => optional($l->activeBinding())->bound_ip,
            ]),
        ]);
    }
}
```

- [ ] **Step 3: LicenseController.transferIp**

Create `app/Http/Controllers/Portal/LicenseController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Portal;
use App\Domain\License\Enums\BindingStatus;
use App\Domain\License\Models\{License, LicenseBinding, LicenseAudit};
use App\Http\Controllers\Controller;
use Illuminate\Http\{RedirectResponse, Request};
use Inertia\{Inertia, Response};

class LicenseController extends Controller {
    public function index(Request $request): Response {
        return Inertia::render('Portal/Licenses', [
            'licenses' => $request->user()->licenses()->with('activeBinding')->latest('purchased_at')->get(),
        ]);
    }

    public function show(Request $request, License $license): Response {
        abort_if($license->customer_id !== $request->user()->id, 403);
        return Inertia::render('Portal/LicenseDetail', [
            'license' => $license->load('bindings', 'audits' => fn($q) => $q->latest()->limit(50)),
        ]);
    }

    public function transferIp(Request $request, License $license): RedirectResponse {
        abort_if($license->customer_id !== $request->user()->id, 403);
        $data = $request->validate([
            'new_ip'         => 'required|ip',
            'new_machine_id' => 'required|string|max:64',
            'new_hostname'   => 'nullable|max:253',
            'reason'         => 'nullable|max:500',
        ]);

        LicenseBinding::where('license_id', $license->id)
            ->where('status', BindingStatus::Active)
            ->update(['status' => BindingStatus::Stale]);

        LicenseBinding::create([
            'license_id' => $license->id,
            'bound_ip'   => $data['new_ip'],
            'bound_machine_id' => $data['new_machine_id'],
            'bound_hostname' => $data['new_hostname'] ?? null,
            'first_seen_at' => now(), 'last_seen_at' => now(),
            'status' => BindingStatus::Active,
        ]);

        LicenseAudit::create([
            'license_id' => $license->id,
            'event'      => 'ip_transfer',
            'payload'    => ['by_customer' => true, 'new_ip' => $data['new_ip'], 'reason' => $data['reason'] ?? null],
            'ip'         => $request->ip(),
            'created_at' => now(),
        ]);

        return back()->with('success', 'IP başarıyla aktarıldı — panel 30 saniyede senkronize olacak.');
    }
}
```

- [ ] **Step 4: InvoiceController.download**

```php
public function download(\App\Domain\Billing\Models\Invoice $invoice, Request $request) {
    abort_if($invoice->order->customer_id !== $request->user()->id, 403);
    return \Illuminate\Support\Facades\Storage::disk('local')->download($invoice->pdf_path, $invoice->number.'.txt');
}
```

(L5'te PDF olacak — şimdilik .txt.)

- [ ] **Step 5: SettingsController — profil + 2FA**

Standart Fortify 2FA toggle + profil update.

- [ ] **Step 6: Vue sayfaları**

Portal/Dashboard.vue, Portal/Licenses.vue (kart liste), Portal/LicenseDetail.vue (binding history + IP transfer form), Portal/Invoices.vue, Portal/Settings.vue.

PortalLayout.vue — sol sidebar (Pano / Lisanslar / Faturalar / Ayarlar) + topbar (e-posta, logout).

- [ ] **Step 7: Customer modeline `orders()` ilişkisi ekle**

```php
public function orders(): HasMany { return $this->hasMany(\App\Domain\Order\Models\Order::class); }
public function licenses(): HasMany { return $this->hasMany(\App\Domain\License\Models\License::class); }
```

- [ ] **Step 8: Tests**

Create `tests/Feature/Portal/PortalTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Models\License;

test('müşteri kendi lisansını görür', function () {
    $c = Customer::factory()->create();
    $l = License::factory()->create(['customer_id' => $c->id]);
    $this->actingAs($c)->get("/portal/lisanslar/{$l->id}")
        ->assertOk()->assertInertia(fn($p) => $p->component('Portal/LicenseDetail'));
});

test('müşteri başkasının lisansını göremez', function () {
    $owner = Customer::factory()->create();
    $other = Customer::factory()->create();
    $l = License::factory()->create(['customer_id' => $owner->id]);
    $this->actingAs($other)->get("/portal/lisanslar/{$l->id}")->assertForbidden();
});

test('IP transfer eski binding\'i stale yapar', function () {
    $c = Customer::factory()->create();
    $l = License::factory()->create(['customer_id' => $c->id]);
    \App\Domain\License\Models\LicenseBinding::factory()->create([
        'license_id' => $l->id, 'bound_ip' => '1.1.1.1', 'status' => 'active',
        'first_seen_at' => now(), 'last_seen_at' => now(),
    ]);

    $this->actingAs($c)->post("/portal/lisanslar/{$l->id}/ip-transfer", [
        'new_ip' => '9.9.9.9', 'new_machine_id' => 'newmid',
    ])->assertRedirect();

    expect($l->bindings()->where('status','stale')->count())->toBe(1);
    expect($l->bindings()->where('status','active')->where('bound_ip','9.9.9.9')->count())->toBe(1);
});
```

- [ ] **Step 9: Run + commit**

```bash
./vendor/bin/pest tests/Feature/Portal/
git add app/Http/Controllers/Portal/ resources/js/Layouts/PortalLayout.vue resources/js/Pages/Portal/ routes/web.php tests/
git commit -m "feat(portal): customer dashboard + lisanslar + IP transfer + faturalar + ayarlar"
```

---

## Phase 8 — End-to-End Validation (Tasks 22-23)

### Task 22: Smoke Routes Test

**Files:**
- Create: `tests/Feature/Smoke/AllRoutesSmokeTest.php`

- [ ] **Step 1: Smoke test — tüm GET route'lar 2xx/3xx**

Create `tests/Feature/Smoke/AllRoutesSmokeTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;
use Database\Seeders\AdminSeeder;
use Illuminate\Support\Facades\Route;

test('tüm public GET route\'lar 200', function () {
    $publicRoutes = ['/', '/fiyatlar', '/ozellikler', '/iletisim', '/gizlilik', '/sozlesme', '/kvkk', '/login', '/register', '/forgot'];
    foreach ($publicRoutes as $url) {
        $this->get($url)->assertOk();
    }
});

test('admin tüm sayfaları açabilir', function () {
    $this->seed(AdminSeeder::class);
    $admin = Customer::where('email','admin@onoxsoft.com')->first();
    foreach (['/admin', '/admin/customers', '/admin/licenses', '/admin/orders', '/admin/crack-reports'] as $url) {
        $this->actingAs($admin)->get($url)->assertOk();
    }
});

test('müşteri portal sayfalarını açabilir', function () {
    $c = Customer::factory()->create();
    foreach (['/portal', '/portal/lisanslar', '/portal/faturalar', '/portal/ayarlar'] as $url) {
        $this->actingAs($c)->get($url)->assertOk();
    }
});
```

- [ ] **Step 2: Run + commit**

```bash
./vendor/bin/pest tests/Feature/Smoke/AllRoutesSmokeTest.php
git add tests/
git commit -m "test(smoke): tüm GET route'lar 2xx döner"
```

---

### Task 23: End-to-End Purchase + Activate + Heartbeat Scenario

**Files:**
- Create: `tests/Feature/Scenarios/FullPurchaseFlowTest.php`

- [ ] **Step 1: E2E senaryo testi (Iyzico mocked)**

Create `tests/Feature/Scenarios/FullPurchaseFlowTest.php`:
```php
<?php
use App\Domain\Customer\Models\Customer;
use App\Domain\License\Enums\{BindingStatus, LicenseStatus, LicenseTier};
use App\Domain\License\Models\{License, LicenseBinding, PurchaseIntent};
use App\Domain\License\Services\{JwtSigner, LicenseIssuer};
use App\Domain\Order\Services\OrderService;
use Illuminate\Support\Facades\Mail;

test('uçtan uca: intent → buy → order → license → activate → heartbeat → transfer-ip', function () {
    Mail::fake();

    // 1) Müşteri kayıt
    $this->postJson('/register', [
        'name' => 'Demo Host', 'email' => 'demo@host.com',
        'password' => 'Sifre1234!', 'password_confirmation' => 'Sifre1234!',
        'kvkk_consent' => true,
    ])->assertStatus(302);
    $customer = Customer::where('email','demo@host.com')->first();

    // 2) Panel intent token üretir, /buy'a yönlendirir
    config(['onoxsoft.intent.secret' => 'integration-test-secret']);
    $intentData = [
        'v'=>1, 'ip'=>'5.5.5.5', 'mid'=>'machine-abc', 'host'=>'panel.host.com',
        'pv'=>'1.0', 'ts'=>time(), 'exp'=>time()+3600, 'nonce'=>'n',
    ];
    $intentData['sig'] = hash_hmac('sha256', json_encode($intentData, JSON_UNESCAPED_SLASHES), 'integration-test-secret');
    $token = rtrim(strtr(base64_encode(json_encode($intentData)), '+/', '-_'), '=');

    app()->instance(\App\Domain\License\Services\PurchaseIntentVerifier::class,
        new \App\Domain\License\Services\PurchaseIntentVerifier('integration-test-secret'));

    $this->actingAs($customer)->get("/buy?intent={$token}")->assertOk();
    expect(PurchaseIntent::where('token', $token)->exists())->toBeTrue();
    $intent = PurchaseIntent::where('token', $token)->first();

    // 3) Order oluştur (manuel — Iyzico bypass)
    $order = app(OrderService::class)->createPending($customer, LicenseTier::Pro, $intent);
    $order->update(['status' => 'paid', 'paid_at' => now()]);
    $intent->update(['order_id' => $order->id]);

    // 4) License issue
    $license = app(LicenseIssuer::class)->issueFromOrder($order->fresh());
    expect($license->tier)->toBe(LicenseTier::Pro);
    Mail::assertQueued(\App\Mail\LicenseActivatedMail::class);

    // 5) Polling → ready_to_activate
    $this->getJson("/api/v1/license/check?intent={$token}")
        ->assertOk()->assertJson(['status' => 'ready_to_activate']);

    // 6) Activate
    $res = $this->postJson('/api/v1/activate', [
        'intent_token' => $token,
        'ip'           => '5.5.5.5',
        'machine_id'   => 'machine-abc',
        'hostname'     => 'panel.host.com',
        'panel_version'=> '1.0',
    ]);
    $res->assertOk();
    $jwt = $res->json('license_jwt');

    $claims = app(JwtSigner::class)->verify($jwt);
    expect($claims['license']['tier'])->toBe('pro');
    expect($claims['license']['bound_ip'])->toBe('5.5.5.5');

    // Binding active
    expect(LicenseBinding::where('license_id', $license->id)->where('status', BindingStatus::Active)->count())->toBe(1);

    // 7) Heartbeat
    $hbBody = ['ip' => '5.5.5.5', 'machine_id' => 'machine-abc'];
    $hbHeaders = $this->signedHeaders($license->uuid, 'POST', 'api/v1/heartbeat', $hbBody);
    $hbRes = $this->postJson('/api/v1/heartbeat', $hbBody, $hbHeaders);
    $hbRes->assertOk()->assertJsonStructure(['license_jwt']);

    expect($license->fresh()->last_heartbeat_at)->not->toBeNull();

    // 8) IP transfer (cloud migration)
    $tBody = ['new_ip' => '7.7.7.7', 'new_machine_id' => 'machine-xyz'];
    $tHeaders = $this->signedHeaders($license->uuid, 'POST', 'api/v1/transfer-ip', $tBody);
    $this->postJson('/api/v1/transfer-ip', $tBody, $tHeaders)->assertOk();

    expect(LicenseBinding::where('license_id', $license->id)
        ->where('status', BindingStatus::Active)->where('bound_ip','7.7.7.7')->count())->toBe(1);
    expect(LicenseBinding::where('license_id', $license->id)
        ->where('status', BindingStatus::Stale)->count())->toBe(1);

    // 9) Audit log dolu
    expect($license->audits()->count())->toBeGreaterThanOrEqual(3); // activate + heartbeat + ip_transfer
});
```

- [ ] **Step 2: Run e2e + commit**

```bash
./vendor/bin/pest tests/Feature/Scenarios/FullPurchaseFlowTest.php
# Expected: 1 test, ~10 assertion, ~3-5 saniye

git add tests/Feature/Scenarios/
git commit -m "test(e2e): tam satın alma + aktivasyon + heartbeat + transfer-ip senaryosu"
```

- [ ] **Step 3: Full test suite çalıştır**

```bash
./vendor/bin/pest
# Expected: ~80-120 test geçer, 0 fail
```

- [ ] **Step 4: L1 release commit (etiketle)**

```bash
git tag -a v0.1.0-L1-MVP -m "L1 MVP complete — License Server tam çalışır durumda"
git log --oneline -25  # commit özetini gör
```

---

## L1 Done — Definition of Done

L1 MVP'nin tamamlanmış sayılması için:

- [ ] Tüm 23 task'ın commit'i var
- [ ] `./vendor/bin/pest` → 100+ test, 0 fail
- [ ] `./vendor/bin/pint` → style check temiz
- [ ] Manuel demo: tarayıcıda `localhost:8000/buy?intent=TEST_TOKEN` açılır, plan seçilir, Iyzico sandbox kartı (`5528790000000008/12/30/123`) ile ödeme başarılı, lisans otomatik üretilir, e-posta gelir, admin paneli lisansı gösterir
- [ ] Postman ile API endpoint'leri test edilebilir:
  - `POST /api/v1/activate` → signed JWT döner
  - `POST /api/v1/heartbeat` → yeni JWT döner
  - `POST /api/v1/transfer-ip` → binding değişir
  - `GET /api/v1/license/check?intent=X` → status döner
- [ ] Customer portal'dan IP transfer testi başarılı

---

## L1'den L2'ye geçiş

L1 sonunda License Server **canlıya çıkmaya hazır** (sandbox modunda). L2 (Panel Hardening) için PANELTR'de:

1. `OnoxsoftLicenseClient` — bu plan'daki API endpoint'lerini çağıran HTTP istemci
2. `JwtVerifier` — burada üretilen public key ile JWT doğrulama
3. `KillSwitch` — Postfix/Dovecot stop logic
4. `EnforceLicense` middleware güncellemesi
5. "Lisans Satın Al" sayfası (`/lisans/satin-al`)
6. Branding fix: "ONOX" → "Onoxsoft Panel"

L2 ayrı plan dosyası olarak yazılacak (~6 hafta).

---

## Açık Sorular (L1 sırasında karara bağlanacak)

- Iyzico sandbox credential nereden alınır → `merchant.iyzipay.com/login` sandbox hesabı oluşturulur
- Domain ne zaman alınır → L0'da
- e-Posta SMTP relay → Mailgun mı, Postmark mı, AWS SES mi? (Phase 0 sonu)
- Test database adı → `onoxsoft_license_test` (.env.testing'de)

---

**Plan versiyonu:** v1.0 (2026-05-13)
**Toplam task:** 23 (Phases 1-8)
**Tahmini effort:** ~270 saat = 8 hafta part-time (haftada 12-15 saat)
**Spec referansı:** `docs/superpowers/specs/2026-05-13-onoxsoft-license-server-design.md`

