output:
Chunk ID: 069874
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1275
Output:
---
name: pest-testing
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT
metadata:
  author: laravel
---

# Pest Testing 4

## Documentation

Use `search-docs` for detailed Pest 4 patterns and documentation.

## Basic Usage

### Creating Tests

All tests must be written using Pest. Use `php artisan make:test --pest {name}`.

### Test Organization

- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.

### Basic Test Structure

Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.

<!-- Basic Pest Test Example -->
```php
it('is true', function () {
    expect(true)->toBeTrue();
});
```

### Running Tests

- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
- Run all tests: `php artisan test --compact`.
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.

## Assertions

Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:

<!-- Pest Response Assertion -->
```php
it('returns all', function () {
    $this->postJson('/api/docs', [])->assertSuccessful();
});
```

| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
| `assertNotFound()` | `assertStatus(404)` |
| `assertForbidden()` | `assertStatus(403)` |

## Mocking

Import mock function before use: `use function Pest\Laravel\mock;`

## Datasets

Use datasets for repetitive tests (validation rules, etc.):

<!-- Pest Dataset Example -->
```php
it('has emails', function (string $email) {
    expect($email)->not->toBeEmpty();
})->with([
    'james' => 'james@laravel.com',
    'taylor' => 'taylor@laravel.com',
]);
```

## Pest 4 Features

| Feature | Purpose |
|---------|---------|
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Test Sharding | Parallel CI runs |
| Architecture Testing | Enforce code conventions |

### Browser Test Example

Browser tests run in real browsers for full integration testing:

- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.

<!-- Pest Browser Test Example -->
```php
it('may reset the password', function () {
    Notification::fake();

    $this->actingAs(User::factory()->create());

    $page = visit('/sign-in');

    $page->assertSee('Sign In')
        ->assertNoJavaScriptErrors()
        ->click('Forgot Password?')
        ->fill('email', 'nuno@laravel.com')
        ->click('Send Reset Link')
        ->assertSee('We have emailed your password reset link!');

    Notification::assertSent(ResetPassword::class);
});
```

### Smoke Testing

Quickly validate multiple pages have no JavaScript errors:

<!-- Pest Smoke Testing Example -->
```php
$pages = visit(['/', '/about', '/contact']);

$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
```

### Visual Regression Testing

Capture and compare screenshots to detect visual changes.

### Test Sharding

Split tests across parallel processes for faster CI runs.

### Architecture Testing

Pest 4 includes architecture testing (from Pest 3):

<!-- Architecture Test Example -->
```php
arch('controllers')
    ->expect('App\Http\Controllers')
    ->toExtendNothing()
    ->toHaveSuffix('Controller');
```

## Common Pitfalls

- Not importing `use function Pest\Laravel\mock;` before using mock
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests


message: Using `laravel-best-practices` for the API/controller route work and `pest-testing` for the verification path. The pending files match the discount API slice from the previous work; I’m cleaning generated browser artifacts before staging.

phase: commentary


type: message

role: assistant

text: Using `laravel-best-practices` for the API/controller route work and `pest-testing` for the verification path. The pending files match the discount API slice from the previous work; I’m cleaning generated browser artifacts before staging.


command:
/bin/zsh
-lc
git clean -nd .playwright-mcp

type: unknown

cmd: git clean -nd .playwright-mcp

status: completed


output:
Chunk ID: d2e657
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


command:
/bin/zsh
-lc
git clean -f .playwright-mcp

cmd: git clean -f .playwright-mcp


output:
Chunk ID: 9a58a2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


command:
/bin/zsh
-lc
git diff --stat

cmd: git diff --stat

aggregated_output:
 routes/api.php    | 11 +++++++++++
 specs/progress.md | 15 +++++++++++----
 2 files changed, 22 insertions(+), 4 deletions(-)


command:
/bin/zsh
-lc
git diff --check

cmd: git diff --check


output:
Chunk ID: 2b4516
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


output:
Chunk ID: 5a78a5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 32
Output:
 routes/api.php    | 11 +++++++++++
 specs/progress.md | 15 +++++++++++----
 2 files changed, 22 insertions(+), 4 deletions(-)


command:
/bin/zsh
-lc
git status --short

cmd: git status --short

aggregated_output:
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/DiscountController.php
?? app/Http/Resources/Admin/V1/DiscountResource.php
?? tests/Feature/Api/AdminDiscountApiTest.php


output:
Chunk ID: d7b666
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 50
Output:
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/DiscountController.php
?? app/Http/Resources/Admin/V1/DiscountResource.php
?? tests/Feature/Api/AdminDiscountApiTest.php


message: Whitespace checks are clean. I’m doing a brief final read of the new controller/resource/test before staging so the commit contains only the intended API surface.


text: Whitespace checks are clean. I’m doing a brief final read of the new controller/resource/test before staging so the commit contains only the intended API surface.


cmd: sed -n '1,260p' app/Http/Controllers/Api/Admin/V1/DiscountController.php


cmd: sed -n '1,180p' app/Http/Resources/Admin/V1/DiscountResource.php


cmd: sed -n '1,260p' tests/Feature/Api/AdminDiscountApiTest.php


cmd: git diff -- routes/api.php specs/progress.md


command: sed -n '1,180p' app/Http/Resources/Admin/V1/DiscountResource.php

DiscountResource.php

type: read

aggregated_output:
<?php

namespace App\Http\Resources\Admin\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class DiscountResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'type' => $this->type?->value,
            'code' => $this->code,
            'value_type' => $this->value_type?->value,
            'value_amount' => $this->value_amount,
            'starts_at' => $this->starts_at?->toIso8601String(),
            'ends_at' => $this->ends_at?->toIso8601String(),
            'usage_limit' => $this->usage_limit,
            'usage_count' => $this->usage_count,
            'rules_json' => $this->rules_json ?? [],
            'status' => $this->status?->value,
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}


command: sed -n '1,260p' app/Http/Controllers/Api/Admin/V1/DiscountController.php

aggregated_output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\DiscountResource;
use App\Models\Discount;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;

class DiscountController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'type' => ['nullable', Rule::in(['code', 'automatic'])],
            'status' => ['nullable', Rule::in(['active', 'expired', 'scheduled'])],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $discounts = Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'type'), fn (Builder $query, string $type) => $query->where('type', $type))
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $this->applyComputedStatus($query, $status))
            ->latest('created_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return DiscountResource::collection($discounts);
    }

    public function store(Request $request, Store $store): JsonResponse
    {
        $this->authorizeStore($request, $store);

        $validated = $this->validatePayload($request, $store);

        $discount = Discount::withoutGlobalScopes()->create($this->attributesForCreate($validated, $store));

        return DiscountResource::make($discount)
            ->response()
            ->setStatusCode(201);
    }

    public function update(Request $request, Store $store, Discount $discount): DiscountResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessDiscountBelongsToStore($discount, $store);

        $validated = $this->validatePayload($request, $store, $discount);

        $discount->update($this->attributesForUpdate($validated, $discount));

        return DiscountResource::make($discount->refresh());
    }

    public function destroy(Request $request, Store $store, Discount $discount): JsonResponse
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessDiscountBelongsToStore($discount, $store);

        $discount->delete();

        return response()->json(['message' => 'Discount deleted']);
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessDiscountBelongsToStore(Discount $discount, Store $store): void
    {
        abort_unless((int) $discount->store_id === $store->getKey(), 404);
    }

    private function applyComputedStatus(Builder $query, string $status): void
    {
        match ($status) {
            'active' => $query
                ->where('status', 'active')
                ->where('starts_at', '<=', now())
                ->where(fn (Builder $query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', now())),
            'expired' => $query->where(fn (Builder $query) => $query->where('status', 'expired')->orWhere('ends_at', '<=', now())),
            'scheduled' => $query->where('starts_at', '>', now()),
        };
    }

    /**
     * @return array<string, mixed>
     */
    private function validatePayload(Request $request, Store $store, ?Discount $discount = null): array
    {
        $creating = $discount === null;
        $type = (string) $request->input('type', $discount?->type->value ?? 'code');
        $valueType = (string) $request->input('value_type', $discount?->value_type->value ?? 'percent');

        $validated = $request->validate([
            'type' => [$creating ? 'required' : 'sometimes', Rule::in(['code', 'automatic'])],
            'code' => [
                Rule::requiredIf($creating && $type === 'code'),
                'nullable',
                'string',
                'max:50',
                function (string $attribute, mixed $value, \Closure $fail) use ($store, $discount): void {
                    if ($value === null || trim((string) $value) === '') {
                        return;
                    }

                    $normalized = Str::upper(trim((string) $value));

                    if ($discount instanceof Discount && $normalized !== (string) $discount->code) {
                        $fail(__('The discount code cannot be changed after creation.'));

                        return;
                    }

                    $exists = Discoun…835 chars truncated…llable',
                'date',
                function (string $attribute, mixed $value, \Closure $fail) use ($request, $discount): void {
                    if ($value === null || $value === '') {
                        return;
                    }

                    $startsAt = $request->input('starts_at', $discount?->starts_at ?? now());

                    if (Carbon::parse($value)->lte(Carbon::parse($startsAt))) {
                        $fail(__('The end date must be after the start date.'));
                    }
                },
            ],
            'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'],
            'status' => ['sometimes', Rule::in(['draft', 'active', 'expired', 'disabled'])],
            'rules_json' => ['sometimes', 'array'],
            'rules_json.minimum_purchase_amount' => ['nullable', 'integer', 'min:0'],
            'rules_json.min_purchase_amount' => ['nullable', 'integer', 'min:0'],
            'rules_json.applicable_product_ids' => ['nullable', 'array'],
            'rules_json.applicable_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
            'rules_json.applicable_collection_ids' => ['nullable', 'array'],
            'rules_json.applicable_collection_ids.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->getKey())],
            'rules_json.customer_eligibility' => ['nullable', Rule::in(['all', 'specific_customers', 'specific_segments'])],
            'rules_json.once_per_customer' => ['nullable', 'boolean'],
            'rules_json.one_per_customer' => ['nullable', 'boolean'],
        ]);

        return $validated;
    }

    /**
     * @return list<mixed>
     */
    private function valueAmountRules(bool $creating, string $valueType): array
    {
        $presence = $creating ? 'required' : 'sometimes';

        return match ($valueType) {
            'percent' => [$presence, 'integer', 'min:1', 'max:100'],
            'free_shipping' => ['sometimes', 'nullable', 'integer', 'min:0'],
            default => [$presence, 'integer', 'min:1'],
        };
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForCreate(array $validated, Store $store): array
    {
        $type = (string) $validated['type'];
        $valueType = (string) $validated['value_type'];

        return [
            'store_id' => $store->getKey(),
            'type' => $type,
            'code' => $type === 'code' ? Str::upper(trim((string) $validated['code'])) : null,
            'value_type' => $valueType,
            'value_amount' => $valueType === 'free_shipping' ? 0 : (int) $validated['value_amount'],
            'starts_at' => $validated['starts_at'] ?? now(),
            'ends_at' => $validated['ends_at'] ?? null,
            'usage_limit' => $validated['usage_limit'] ?? null,
            'usage_count' => 0,
            'rules_json' => $this->rulesPayload($validated['rules_json'] ?? []),
            'status' => $validated['status'] ?? 'active',
        ];
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForUpdate(array $validated, Discount $discount): array
    {
        $attributes = [];
        $valueType = (string) data_get($validated, 'value_type', $discount->value_type->value);

        foreach (['type', 'value_type', 'starts_at', 'ends_at', 'usage_limit', 'status'] as $field) {
            if (array_key_exists($field, $validated)) {
                $attributes[$field] = $validated[$field];
            }
        }

        if (array_key_exists('value_amount', $validated)) {
            $attributes['value_amount'] = $valueType === 'free_shipping' ? 0 : (int) $validated['value_amount'];
        } elseif (array_key_exists('value_type', $validated) && $valueType === 'free_shipping') {
            $attributes['value_amount'] = 0;
        }

        if (array_key_exists('rules_json', $validated)) {
            $attributes['rules_json'] = $this->rulesPayload($validated['rules_json']);
        }

        return $attributes;
    }

    /**
     * @param  array<string, mixed>  $rules
     * @return array<string, mixed>
     */
    private function rulesPayload(array $rules): array
    {
        return [
            'min_purchase_amount' => (int) data_get($rules, 'min_purchase_amount', data_get($rules, 'minimum_purchase_amount', 0)),
            'applicable_product_ids' => $this->integerList(data_get($rules, 'applicable_product_ids', [])),
            'applicable_collection_ids' => $this->integerList(data_get($rules, 'applicable_collection_ids', [])),
            'customer_eligibility' => data_get($rules, 'customer_eligibility', 'all'),
            'one_per_customer' => (bool) data_get($rules, 'one_per_customer', data_get($rules, 'once_per_customer', false)),
        ];
    }

    /**
     * @return list<int>
     */


output:
Chunk ID: 1e66b2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2709
Output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\DiscountResource;
use App\Models\Discount;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;

class DiscountController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'type' => ['nullable', Rule::in(['code', 'automatic'])],
            'status' => ['nullable', Rule::in(['active', 'expired', 'scheduled'])],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $discounts = Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'type'), fn (Builder $query, string $type) => $query->where('type', $type))
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $this->applyComputedStatus($query, $status))
            ->latest('created_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return DiscountResource::collection($discounts);
    }

    public function store(Request $request, Store $store): JsonResponse
    {
        $this->authorizeStore($request, $store);

        $validated = $this->validatePayload($request, $store);

        $discount = Discount::withoutGlobalScopes()->create($this->attributesForCreate($validated, $store));

        return DiscountResource::make($discount)
            ->response()
            ->setStatusCode(201);
    }

    public function update(Request $request, Store $store, Discount $discount): DiscountResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessDiscountBelongsToStore($discount, $store);

        $validated = $this->validatePayload($request, $store, $discount);

        $discount->update($this->attributesForUpdate($validated, $discount));

        return DiscountResource::make($discount->refresh());
    }

    public function destroy(Request $request, Store $store, Discount $discount): JsonResponse
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessDiscountBelongsToStore($discount, $store);

        $discount->delete();

        return response()->json(['message' => 'Discount deleted']);
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessDiscountBelongsToStore(Discount $discount, Store $store): void
    {
        abort_unless((int) $discount->store_id === $store->getKey(), 404);
    }

    private function applyComputedStatus(Builder $query, string $status): void
    {
        match ($status) {
            'active' => $query
                ->where('status', 'active')
                ->where('starts_at', '<=', now())
                ->where(fn (Builder $query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', now())),
            'expired' => $query->where(fn (Builder $query) => $query->where('status', 'expired')->orWhere('ends_at', '<=', now())),
            'scheduled' => $query->where('starts_at', '>', now()),
        };
    }

    /**
     * @return array<string, mixed>
     */
    private function validatePayload(Request $request, Store $store, ?Discount $discount = null): array
    {
        $creating = $discount === null;
        $type = (string) $request->input('type', $discount?->type->value ?? 'code');
        $valueType = (string) $request->input('value_type', $discount?->value_type->value ?? 'percent');

        $validated = $request->validate([
            'type' => [$creating ? 'required' : 'sometimes', Rule::in(['code', 'automatic'])],
            'code' => [
                Rule::requiredIf($creating && $type === 'code'),
                'nullable',
                'string',
                'max:50',
                function (string $attribute, mixed $value, \Closure $fail) use ($store, $discount): void {
                    if ($value === null || trim((string) $value) === '') {
                        return;
                    }

                    $normalized = Str::upper(trim((string) $value));

                    if ($discount instanceof Discount && $normalized !== (string) $discount->code) {
                        $fail(__('The discount code cannot be changed after creation.'));

                        return;
                    }

                    $exists = Discount::withoutGlobalScopes()
                        ->where('store_id', $store->getKey())
                        ->whereRaw('lower(code) = ?', [Str::lower($normalized)])
                        ->when($discount instanceof Discount, fn (Builder $query) => $query->whereKeyNot($discount->getKey()))
                        ->exists();

                    if ($exists) {
                        $fail(__('The discount code has already been taken.'));
                    }
                },
            ],
            'value_type' => [$creating ? 'required' : 'sometimes', Rule::in(['fixed', 'percent', 'free_shipping'])],
            'value_amount' => $this->valueAmountRules($creating, $valueType),
            'starts_at' => ['sometimes', 'nullable', 'date'],
            'ends_at' => [
                'sometimes',
                'nullable',
                'date',
                function (string $attribute, mixed $value, \Closure $fail) use ($request, $discount): void {
                    if ($value === null || $value === '') {
                        return;
                    }

                    $startsAt = $request->input('starts_at', $discount?->starts_at ?? now());

                    if (Carbon::parse($value)->lte(Carbon::parse($startsAt))) {
                        $fail(__('The end date must be after the start date.'));
                    }
                },
            ],
            'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'],
            'status' => ['sometimes', Rule::in(['draft', 'active', 'expired', 'disabled'])],
            'rules_json' => ['sometimes', 'array'],
            'rules_json.minimum_purchase_amount' => ['nullable', 'integer', 'min:0'],
            'rules_json.min_purchase_amount' => ['nullable', 'integer', 'min:0'],
            'rules_json.applicable_product_ids' => ['nullable', 'array'],
            'rules_json.applicable_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
            'rules_json.applicable_collection_ids' => ['nullable', 'array'],
            'rules_json.applicable_collection_ids.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->getKey())],
            'rules_json.customer_eligibility' => ['nullable', Rule::in(['all', 'specific_customers', 'specific_segments'])],
            'rules_json.once_per_customer' => ['nullable', 'boolean'],
            'rules_json.one_per_customer' => ['nullable', 'boolean'],
        ]);

        return $validated;
    }

    /**
     * @return list<mixed>
     */
    private function valueAmountRules(bool $creating, string $valueType): array
    {
        $presence = $creating ? 'required' : 'sometimes';

        return match ($valueType) {
            'percent' => [$presence, 'integer', 'min:1', 'max:100'],
            'free_shipping' => ['sometimes', 'nullable', 'integer', 'min:0'],
            default => [$presence, 'integer', 'min:1'],
        };
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForCreate(array $validated, Store $store): array
    {
        $type = (string) $validated['type'];
        $valueType = (string) $validated['value_type'];

        return [
            'store_id' => $store->getKey(),
            'type' => $type,
            'code' => $type === 'code' ? Str::upper(trim((string) $validated['code'])) : null,
            'value_type' => $valueType,
            'value_amount' => $valueType === 'free_shipping' ? 0 : (int) $validated['value_amount'],
            'starts_at' => $validated['starts_at'] ?? now(),
            'ends_at' => $validated['ends_at'] ?? null,
            'usage_limit' => $validated['usage_limit'] ?? null,
            'usage_count' => 0,
            'rules_json' => $this->rulesPayload($validated['rules_json'] ?? []),
            'status' => $validated['status'] ?? 'active',
        ];
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForUpdate(array $validated, Discount $discount): array
    {
        $attributes = [];
        $valueType = (string) data_get($validated, 'value_type', $discount->value_type->value);

        foreach (['type', 'value_type', 'starts_at', 'ends_at', 'usage_limit', 'status'] as $field) {
            if (array_key_exists($field, $validated)) {
                $attributes[$field] = $validated[$field];
            }
        }

        if (array_key_exists('value_amount', $validated)) {
            $attributes['value_amount'] = $valueType === 'free_shipping' ? 0 : (int) $validated['value_amount'];
        } elseif (array_key_exists('value_type', $validated) && $valueType === 'free_shipping') {
            $attributes['value_amount'] = 0;
        }

        if (array_key_exists('rules_json', $validated)) {
            $attributes['rules_json'] = $this->rulesPayload($validated['rules_json']);
        }

        return $attributes;
    }

    /**
     * @param  array<string, mixed>  $rules
     * @return array<string, mixed>
     */
    private function rulesPayload(array $rules): array
    {
        return [
            'min_purchase_amount' => (int) data_get($rules, 'min_purchase_amount', data_get($rules, 'minimum_purchase_amount', 0)),
            'applicable_product_ids' => $this->integerList(data_get($rules, 'applicable_product_ids', [])),
            'applicable_collection_ids' => $this->integerList(data_get($rules, 'applicable_collection_ids', [])),
            'customer_eligibility' => data_get($rules, 'customer_eligibility', 'all'),
            'one_per_customer' => (bool) data_get($rules, 'one_per_customer', data_get($rules, 'once_per_customer', false)),
        ];
    }

    /**
     * @return list<int>
     */


output:
Chunk ID: b4a889
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 272
Output:
<?php

namespace App\Http\Resources\Admin\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class DiscountResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'type' => $this->type?->value,
            'code' => $this->code,
            'value_type' => $this->value_type?->value,
            'value_amount' => $this->value_amount,
            'starts_at' => $this->starts_at?->toIso8601String(),
            'ends_at' => $this->ends_at?->toIso8601String(),
            'usage_limit' => $this->usage_limit,
            'usage_count' => $this->usage_count,
            'rules_json' => $this->rules_json ?? [],
            'status' => $this->status?->value,
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}


command: sed -n '1,260p' tests/Feature/Api/AdminDiscountApiTest.php

aggregated_output:
<?php

use App\Models\Collection;
use App\Models\Discount;
use App\Models\Product;
use App\Models\Store;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminDiscountApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function adminDiscountApiUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\OauthToken, plain_text: string}
 */
function adminDiscountApiToken(Store $store, array $abilities): array
{
    return app(WebhookService::class)->createApiToken($store, 'Discount integration', $abilities);
}

test('admin discount api lists creates updates and deletes discounts', function (): void {
    $store = adminDiscountApiStore();
    $product = Product::factory()->withDefaultVariant()->create(['store_id' => $store->getKey()]);
    $collection = Collection::factory()->create(['store_id' => $store->getKey()]);
    $user = adminDiscountApiUser();

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code&status=active")
        ->assertOk();

    $createResponse = $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [
            'type' => 'code',
            'code' => 'api20',
            'value_type' => 'percent',
            'value_amount' => 20,
            'starts_at' => now()->subHour()->toIso8601String(),
            'ends_at' => now()->addMonth()->toIso8601String(),
            'usage_limit' => 50,
            'rules_json' => [
                'minimum_purchase_amount' => 5000,
                'applicable_product_ids' => [$product->getKey()],
                'applicable_collection_ids' => [$collection->getKey()],
                'customer_eligibility' => 'all',
                'once_per_customer' => true,
            ],
        ])
        ->assertCreated()
        ->assertJsonPath('data.code', 'API20')
        ->assertJsonPath('data.rules_json.min_purchase_amount', 5000)
        ->assertJsonPath('data.rules_json.one_per_customer', true);

    $discount = Discount::withoutGlobalScopes()->findOrFail($createResponse->json('data.id'));

    expect($discount->rules_json['applicable_product_ids'])->toBe([$product->getKey()])
        ->and($discount->rules_json['applicable_collection_ids'])->toBe([$collection->getKey()]);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}", [
            'value_type' => 'fixed',
            'value_amount' => 750,
            'usage_limit' => 25,
            'status' => 'disabled',
        ])
        ->assertOk()
        ->assertJsonPath('data.value_type', 'fixed')
        ->assertJsonPath('data.value_amount', 750)
        ->assertJsonPath('data.status', 'disabled');

    $this->actingAs($user)
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertOk()
        ->assertJsonPath('message', 'Discount deleted');

    expect(Discount::withoutGlobalScopes()->whereKey($discount->getKey())->exists())->toBeFalse();
});

test('admin discount api enforces token abilities and store scope', function (): void {
    $store = adminDiscountApiStore();
    $otherStore = Store::factory()->create();
    $discount = Discount::factory()->create([
        'store_id' => $store->getKey(),
        'code' => 'TOKEN25',
    ]);
    $readToken = adminDiscountApiToken($store, ['read-discounts']);
    $writeToken = adminDiscountApiToken($store, ['write-discounts']);
    $otherStoreToken = adminDiscountApiToken($otherStore, ['read-discounts']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code")
        ->assertOk()
        ->assertJsonFragment(['code' => 'TOKEN25']);

    $this->withToken($readToken['plain_text'])
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertForbidden();

    $this->withToken($otherStoreToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts")
        ->assertForbidden();

    $this->withToken($writeToken['plain_text'])
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertOk();
});

test('admin discount api validates code dates and scoped rule resources', function (): void {
    $store = adminDiscountApiStore();
    $existing = Discount::factory()->create([
        'store_id' => $store->getKey(),
        'code' => 'EXISTING',
    ]);
    $otherStoreProduct = Product::factory()
        ->withDefaultVariant()
        ->create(['store_id' => Store::factory()->create()->getKey()]);

    $this->actingAs(adminDiscountApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [
            'type' => 'code',
            'code' => Str::lower($existing->code),
            'value_type' => 'percent',
            'value_amount' => 101,
            'starts_at' => now()->addDay()->toIso8601String(),
            'ends_at' => now()->subDay()->toIso8601String(),
            'rules_json' => [
                'applicable_product_ids' => [$otherStoreProduct->getKey()],
            ],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['code', 'value_amount', 'ends_at', 'rules_json.applicable_product_ids.0']);
});


output:
Chunk ID: de1910
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1433
Output:
<?php

use App\Models\Collection;
use App\Models\Discount;
use App\Models\Product;
use App\Models\Store;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminDiscountApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function adminDiscountApiUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\OauthToken, plain_text: string}
 */
function adminDiscountApiToken(Store $store, array $abilities): array
{
    return app(WebhookService::class)->createApiToken($store, 'Discount integration', $abilities);
}

test('admin discount api lists creates updates and deletes discounts', function (): void {
    $store = adminDiscountApiStore();
    $product = Product::factory()->withDefaultVariant()->create(['store_id' => $store->getKey()]);
    $collection = Collection::factory()->create(['store_id' => $store->getKey()]);
    $user = adminDiscountApiUser();

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code&status=active")
        ->assertOk();

    $createResponse = $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [
            'type' => 'code',
            'code' => 'api20',
            'value_type' => 'percent',
            'value_amount' => 20,
            'starts_at' => now()->subHour()->toIso8601String(),
            'ends_at' => now()->addMonth()->toIso8601String(),
            'usage_limit' => 50,
            'rules_json' => [
                'minimum_purchase_amount' => 5000,
                'applicable_product_ids' => [$product->getKey()],
                'applicable_collection_ids' => [$collection->getKey()],
                'customer_eligibility' => 'all',
                'once_per_customer' => true,
            ],
        ])
        ->assertCreated()
        ->assertJsonPath('data.code', 'API20')
        ->assertJsonPath('data.rules_json.min_purchase_amount', 5000)
        ->assertJsonPath('data.rules_json.one_per_customer', true);

    $discount = Discount::withoutGlobalScopes()->findOrFail($createResponse->json('data.id'));

    expect($discount->rules_json['applicable_product_ids'])->toBe([$product->getKey()])
        ->and($discount->rules_json['applicable_collection_ids'])->toBe([$collection->getKey()]);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}", [
            'value_type' => 'fixed',
            'value_amount' => 750,
            'usage_limit' => 25,
            'status' => 'disabled',
        ])
        ->assertOk()
        ->assertJsonPath('data.value_type', 'fixed')
        ->assertJsonPath('data.value_amount', 750)
        ->assertJsonPath('data.status', 'disabled');

    $this->actingAs($user)
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertOk()
        ->assertJsonPath('message', 'Discount deleted');

    expect(Discount::withoutGlobalScopes()->whereKey($discount->getKey())->exists())->toBeFalse();
});

test('admin discount api enforces token abilities and store scope', function (): void {
    $store = adminDiscountApiStore();
    $otherStore = Store::factory()->create();
    $discount = Discount::factory()->create([
        'store_id' => $store->getKey(),
        'code' => 'TOKEN25',
    ]);
    $readToken = adminDiscountApiToken($store, ['read-discounts']);
    $writeToken = adminDiscountApiToken($store, ['write-discounts']);
    $otherStoreToken = adminDiscountApiToken($otherStore, ['read-discounts']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code")
        ->assertOk()
        ->assertJsonFragment(['code' => 'TOKEN25']);

    $this->withToken($readToken['plain_text'])
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertForbidden();

    $this->withToken($otherStoreToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts")
        ->assertForbidden();

    $this->withToken($writeToken['plain_text'])
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertOk();
});

test('admin discount api validates code dates and scoped rule resources', function (): void {
    $store = adminDiscountApiStore();
    $existing = Discount::factory()->create([
        'store_id' => $store->getKey(),
        'code' => 'EXISTING',
    ]);
    $otherStoreProduct = Product::factory()
        ->withDefaultVariant()
        ->create(['store_id' => Store::factory()->create()->getKey()]);

    $this->actingAs(adminDiscountApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [
            'type' => 'code',
            'code' => Str::lower($existing->code),
            'value_type' => 'percent',
            'value_amount' => 101,
            'starts_at' => now()->addDay()->toIso8601String(),
            'ends_at' => now()->subDay()->toIso8601String(),
            'rules_json' => [
                'applicable_product_ids' => [$otherStoreProduct->getKey()],
            ],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['code', 'value_amount', 'ends_at', 'rules_json.applicable_product_ids.0']);
});


command: git diff -- routes/api.php specs/progress.md

aggregated_output:
diff --git a/routes/api.php b/routes/api.php
index a197d944..a0167e4e 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -2,6 +2,7 @@

 use App\Http\Controllers\Api\Admin\V1\CollectionController as AdminCollectionController;
 use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
+use App\Http\Controllers\Api\Admin\V1\DiscountController as AdminDiscountController;
 use App\Http\Controllers\Api\Admin\V1\OrderController as AdminOrderController;
 use App\Http\Controllers\Api\Admin\V1\OrderFulfillmentController as AdminOrderFulfillmentController;
 use App\Http\Controllers\Api\Admin\V1\OrderRefundController as AdminOrderRefundController;
@@ -73,6 +74,16 @@
             Route::delete('collections/{collection}', [AdminCollectionController::class, 'destroy'])->name('collections.destroy');
         });

+        Route::middleware('admin.api:read-discounts')->group(function (): void {
+            Route::get('discounts', [AdminDiscountController::class, 'index'])->name('discounts.index');
+        });
+
+        Route::middleware('admin.api:write-discounts')->group(function (): void {
+            Route::post('discounts', [AdminDiscountController::class, 'store'])->name('discounts.store');
+            Route::put('discounts/{discount}', [AdminDiscountController::class, 'update'])->name('discounts.update');
+            Route::delete('discounts/{discount}', [AdminDiscountController::class, 'destroy'])->name('discounts.destroy');
+        });
+
         Route::middleware('admin.api:read-orders')->group(function (): void {
             Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index');
             Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
diff --git a/specs/progress.md b/specs/progress.md
index f684edc7..d247aedd 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -27,7 +27,7 @@ ## Acceptance Checklist
 | Area | Criteria Source | Status | Evidence |
 | --- | --- | --- | --- |
 | Database schema | `specs/01-DATABASE-SCHEMA.md` | partial | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, and Phase 7 search/analytics/apps/webhooks tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, and webhook_deliveries. |
-| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining non-order admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/ac…6332 chars truncated… 2026-05-04: `php artisan test --compact` passed after the admin discount API changes: 218 tests, 1200 assertions.

 ## Decisions

@@ -371,7 +378,7 @@ ## Decisions
 - Admin order Livewire components persist locked `storeId`/`orderId` state and reload orders without global scopes but with explicit store constraints before each service-backed action.
 - Admin order actions deliberately delegate to `OrderService`, `RefundService`, and `FulfillmentService` so UI validation cannot bypass payment, refund, fulfillment, or inventory guards.
 - Storefront order lookup uses a deterministic HMAC access token derived from store/order identity so confirmation links can be token-gated without adding a new runtime column.
-- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, and order fulfillment/refund mutations.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, and order fulfillment/refund mutations.
 - Admin dashboard sales and top-product metrics are derived from paid or partially refunded orders; the dedicated analytics page now reads analytics event and daily aggregate tables.
 - Customer addresses are scoped through the locked customer id because `customer_addresses` intentionally belongs to customers and has no direct `store_id` column.
 - Admin/store Livewire route middleware is registered with Livewire persistent middleware so component POST requests keep `current_store` binding and store-role checks after the initial full-page route load.
@@ -402,10 +409,10 @@ ## Open Issues

 - Customer password reset is implemented under `/account/forgot-password` and `/account/reset-password/{token}`; the exact spec root paths remain occupied by the existing Fortify starter/admin reset routes.
 - `php artisan route:list --except-vendor` hides Livewire full-page routes because their controller is vendor-provided; path-filtered route-list commands are used as evidence for those routes.
-- Non-order admin REST write endpoints beyond collections and the larger admin REST surfaces for discounts, settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST surfaces for settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
 - Full automated browser suites from Spec 08 are still incomplete beyond the initial Pest browser smoke coverage for storefront, admin, and mobile rendering.
 - SQLite enum/check constraints from the schema spec are not yet explicitly enforced as database `CHECK` constraints; enum validation is currently enforced through casts/services/model invariants.

 ## Completion Summary

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.


output:
Chunk ID: 47da14
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 4083
Output:
diff --git a/routes/api.php b/routes/api.php
index a197d944..a0167e4e 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -2,6 +2,7 @@

 use App\Http\Controllers\Api\Admin\V1\CollectionController as AdminCollectionController;
 use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
+use App\Http\Controllers\Api\Admin\V1\DiscountController as AdminDiscountController;
 use App\Http\Controllers\Api\Admin\V1\OrderController as AdminOrderController;
 use App\Http\Controllers\Api\Admin\V1\OrderFulfillmentController as AdminOrderFulfillmentController;
 use App\Http\Controllers\Api\Admin\V1\OrderRefundController as AdminOrderRefundController;
@@ -73,6 +74,16 @@
             Route::delete('collections/{collection}', [AdminCollectionController::class, 'destroy'])->name('collections.destroy');
         });

+        Route::middleware('admin.api:read-discounts')->group(function (): void {
+            Route::get('discounts', [AdminDiscountController::class, 'index'])->name('discounts.index');
+        });
+
+        Route::middleware('admin.api:write-discounts')->group(function (): void {
+            Route::post('discounts', [AdminDiscountController::class, 'store'])->name('discounts.store');
+            Route::put('discounts/{discount}', [AdminDiscountController::class, 'update'])->name('discounts.update');
+            Route::delete('discounts/{discount}', [AdminDiscountController::class, 'destroy'])->name('discounts.destroy');
+        });
+
         Route::middleware('admin.api:read-orders')->group(function (): void {
             Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index');
             Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
diff --git a/specs/progress.md b/specs/progress.md
index f684edc7..d247aedd 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -27,7 +27,7 @@ ## Acceptance Checklist
 | Area | Criteria Source | Status | Evidence |
 | --- | --- | --- | --- |
 | Database schema | `specs/01-DATABASE-SCHEMA.md` | partial | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, and Phase 7 search/analytics/apps/webhooks tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, and webhook_deliveries. |
-| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining non-order admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
 | Admin UI | `specs/03-ADMIN-UI.md` | partial | Flux/Livewire admin shell now includes dashboard KPI/date-range reporting, analytics reporting with filters/export, catalog/product/collection/inventory management, product option matrix generation, product media upload/alt text/reorder/delete controls, order/customer/discount management, page index/form, nested navigation menu editor with drag sorting and parent selection, theme grid, theme settings editor with iframe preview, theme file list/editor, general settings, domain management, shipping zone/rate management, tax settings, checkout policy settings, notification settings, search settings/reindexing, installed app list/detail, and developer token/webhook management with auth protection and store scoping. |
 | Storefront UI | `specs/04-STOREFRONT-UI.md` | partial | Storefront layout, home, collections index/detail, product detail, SQLite-backed search with filters/sort/pagination, breadcrumbs, price display, product cards, DB-backed pages, seeded navigation menus with one-level dropdown children, cached theme settings, product add-to-cart persistence, cart drawer, cart page with discount and shipping estimates, checkout address/shipping/discount/payment-selection/order-submission UI, order confirmation, customer password reset forms, and customer order list/detail views render seeded and runtime data. |
 | Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService` with per-store synonym expansion and stop-word removal, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks and automatic/code order-line attribution, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
@@ -338,6 +338,13 @@ ## Verification Evidence
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin collection API changes: 28 tests, 218 assertions.
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 12 admin API routes, including collection list/create/update/delete.
 - 2026-05-04: `php artisan test --compact` passed after the admin collection API changes: 215 tests, 1175 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 validation, date validation, prohibited/unique update-field validation, JSON API testing, and Pest 4 docs before the admin discount API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/DiscountController --no-interaction`, `php artisan make:resource Admin/V1/DiscountResource --no-interaction`, and `php artisan make:test Api/AdminDiscountApiTest --pest --no-interaction` created the discount API controller/resource and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin discount API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminDiscountApiTest.php` passed after the admin discount API changes: 3 tests, 25 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin discount API changes: 31 tests, 243 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 16 admin API routes, including discount list/create/update/delete.
+- 2026-05-04: `php artisan test --compact` passed after the admin discount API changes: 218 tests, 1200 assertions.

 ## Decisions

@@ -371,7 +378,7 @@ ## Decisions
 - Admin order Livewire components persist locked `storeId`/`orderId` state and reload orders without global scopes but with explicit store constraints before each service-backed action.
 - Admin order actions deliberately delegate to `OrderService`, `RefundService`, and `FulfillmentService` so UI validation cannot bypass payment, refund, fulfillment, or inventory guards.
 - Storefront order lookup uses a deterministic HMAC access token derived from store/order identity so confirmation links can be token-gated without adding a new runtime column.
-- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, and order fulfillment/refund mutations.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, and order fulfillment/refund mutations.
 - Admin dashboard sales and top-product metrics are derived from paid or partially refunded orders; the dedicated analytics page now reads analytics event and daily aggregate tables.
 - Customer addresses are scoped through the locked customer id because `customer_addresses` intentionally belongs to customers and has no direct `store_id` column.
 - Admin/store Livewire route middleware is registered with Livewire persistent middleware so component POST requests keep `current_store` binding and store-role checks after the initial full-page route load.
@@ -402,10 +409,10 @@ ## Open Issues

 - Customer password reset is implemented under `/account/forgot-password` and `/account/reset-password/{token}`; the exact spec root paths remain occupied by the existing Fortify starter/admin reset routes.
 - `php artisan route:list --except-vendor` hides Livewire full-page routes because their controller is vendor-provided; path-filtered route-list commands are used as evidence for those routes.
-- Non-order admin REST write endpoints beyond collections and the larger admin REST surfaces for discounts, settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST surfaces for settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
 - Full automated browser suites from Spec 08 are still incomplete beyond the initial Pest browser smoke coverage for storefront, admin, and mobile rendering.
 - SQLite enum/check constraints from the schema spec are not yet explicitly enforced as database `CHECK` constraints; enum validation is currently enforced through casts/services/model invariants.

 ## Completion Summary

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.


cmd: sed -n '260,520p' app/Http/Controllers/Api/Admin/V1/DiscountController.php


command: sed -n '260,520p' app/Http/Controllers/Api/Admin/V1/DiscountController.php

aggregated_output:
     */
    private function integerList(mixed $value): array
    {
        return collect(is_array($value) ? $value : [])
            ->map(fn (mixed $id): int => (int) $id)
            ->unique()
            ->values()
            ->all();
    }
}


output:
Chunk ID: 5d455e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 63
Output:
     */
    private function integerList(mixed $value): array
    {
        return collect(is_array($value) ? $value : [])
            ->map(fn (mixed $id): int => (int) $id)
            ->unique()
            ->values()
            ->all();
    }
}


message: The final read matches the intended scope: a store-scoped discount CRUD API, JSON resource, route bindings, and focused feature coverage. I’m staging those files and committing this slice now.


text: The final read matches the intended scope: a store-scoped discount CRUD API, JSON resource, route bindings, and focused feature coverage. I’m staging those files and committing this slice now.


cmd: git add app/Http/Controllers/Api/Admin/V1/DiscountController.php app/Http/Resources/Admin/V1/DiscountResource.php routes/api.php specs/progress.md tests/Feature/Api/AdminDiscountApiTest.php


command: git add app/Http/Controllers/Api/Admin/V1/DiscountController.php app/Http/Resources/Admin/V1/DiscountResource.php routes/api.php specs/progress.md tests/Feature/Api/AdminDiscountApiTest.php


output:
Chunk ID: a171ad
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


command:
/bin/zsh
-lc
git diff --cached --stat

cmd: git diff --cached --stat

aggregated_output:
 .../Api/Admin/V1/DiscountController.php            | 269 +++++++++++++++++++++
 app/Http/Resources/Admin/V1/DiscountResource.php   |  34 +++
 routes/api.php                                     |  11 +
 specs/progress.md                                  |  15 +-
 tests/Feature/Api/AdminDiscountApiTest.php         | 149 ++++++++++++
 5 files changed, 474 insertions(+), 4 deletions(-)


output:
Chunk ID: b9115b
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 97
Output:
 .../Api/Admin/V1/DiscountController.php            | 269 +++++++++++++++++++++
 app/Http/Resources/Admin/V1/DiscountResource.php   |  34 +++
 routes/api.php                                     |  11 +
 specs/progress.md                                  |  15 +-
 tests/Feature/Api/AdminDiscountApiTest.php         | 149 ++++++++++++
 5 files changed, 474 insertions(+), 4 deletions(-)


aggregated_output:
A  app/Http/Controllers/Api/Admin/V1/DiscountController.php
A  app/Http/Resources/Admin/V1/DiscountResource.php
M  routes/api.php
M  specs/progress.md
A  tests/Feature/Api/AdminDiscountApiTest.php


output:
Chunk ID: b228c1
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 50
Output:
A  app/Http/Controllers/Api/Admin/V1/DiscountController.php
A  app/Http/Resources/Admin/V1/DiscountResource.php
M  routes/api.php
M  specs/progress.md
A  tests/Feature/Api/AdminDiscountApiTest.php


cmd: git commit -m "Add admin discount API endpoints"


command: git commit -m "Add admin discount API endpoints"

aggregated_output:
[2026-05-04-codex-5-5-goal f138cefa] Add admin discount API endpoints
 5 files changed, 474 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Controllers/Api/Admin/V1/DiscountController.php
 create mode 100644 app/Http/Resources/Admin/V1/DiscountResource.php
 create mode 100644 tests/Feature/Api/AdminDiscountApiTest.php


output:
Chunk ID: a15bcc
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 83
Output:
[2026-05-04-codex-5-5-goal f138cefa] Add admin discount API endpoints
 5 files changed, 474 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Controllers/Api/Admin/V1/DiscountController.php
 create mode 100644 app/Http/Resources/Admin/V1/DiscountResource.php
 create mode 100644 tests/Feature/Api/AdminDiscountApiTest.php


message: Committed `f138cefa` for the discount API. I’m moving to the next tracked gap in `specs/progress.md`; the smallest useful next slice is another admin REST surface, likely content/pages.


text: Committed `f138cefa` for the discount API. I’m moving to the next tracked gap in `specs/progress.md`; the smallest useful next slice is another admin REST surface, likely content/pages.


cmd: rg -n "pages|content|Admin REST|API" specs/02-API-ROUTES.md specs/09-IMPLEMENTATION-ROADMAP.md specs/01-DATABASE-SCHEMA.md specs/03-ADMIN-UI.md


command:
/bin/zsh
-lc
sed -n '1,240p' specs/02-API-ROUTES.md

cmd: sed -n '1,240p' specs/02-API-ROUTES.md

aggregated_output:
# API and Routing Specification

This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.

All monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).

---

## 1. Web Routes (Blade/Livewire Pages)

### 1.1 Admin Auth Routes

No middleware beyond the default `web` group. These routes handle admin login and logout.

| Method | Path | Description | Component / Action |
|--------|------|-------------|--------------------|
| GET | `/admin/login` | Admin login page | `Livewire: Admin\Auth\Login` |
| POST | `/admin/login` | Process admin login | Handled by Livewire action |
| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |
| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\Auth\ForgotPassword` |
| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |
| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\Auth\ResetPassword` |
| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |

Rate limit: `login` tier (5 per minute per IP).

---

### 1.2 Admin Routes

**Prefix:** `/admin`
**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`

All admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).

> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.

#### Dashboard

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\Dashboard` |

#### Products

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\Products\Index` |
| GET | `/admin/products/create` | Create product form | `Admin\Products\Create` |
| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |

#### Inventory

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/inventory` | Inventory management page | `Admin\Inventory\Index` |

#### Collections

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/collections` | Collection list | `Admin\Collections\Index` |
| GET | `/admin/collections/create` | Create collection form | `Admin\Collections\Create` |
| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\Collections\Edit` |

#### Orders

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/orders` | Order list with status filters | `Admin\Orders\Index` |
| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |

#### Customers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/customers` | Customer list | `Admin\Customers\Index` |
| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |

#### Discounts

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/discounts` | Discount list | `Admin\Discounts\Index` |
| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\Discounts\Create` |
| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\Discounts\Edit` |

#### Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\Settings\Index` |
| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\Settings\Shipping` |
| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\Settings\Taxes` |

#### Themes and Online Store

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\Themes\Index` |
| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\Themes\Editor` |

#### Pages

| Method | Path | Description | Component |
|--------|------|--…167 chars truncated… |
| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\Pages\Edit` |

#### Navigation

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/navigation` | Navigation menus management | `Admin\Navigation\Index` |

#### Apps

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\Apps\Index` |
| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\Apps\Show` |

#### Developers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |

> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.

#### Analytics

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |

#### Search Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\Search\Settings` |

---

### 1.3 Storefront Routes

**Middleware:** `web`, `store.resolve`

These routes serve the public-facing storefront using Blade templates rendered through the active theme engine.

| Method | Path | Description | Component / View |
|--------|------|-------------|------------------|
| GET | `/` | Home page | `Storefront\Home` |
| GET | `/collections` | All collections listing | `Storefront\Collections\Index` |
| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\Collections\Show` |
| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\Products\Show` |
| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\Cart\Show` |
| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\Search\Index` |
| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\Pages\Show` |

#### Customer Account Routes

**Additional middleware:** `auth:customer`

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/account` | Customer account overview | `Storefront\Account\Dashboard` |
| GET | `/account/orders` | Order history list | `Storefront\Account\Orders\Index` |
| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\Account\Orders\Show` |
| GET | `/account/addresses` | Address book management | `Storefront\Account\Addresses\Index` |

#### Customer Auth Routes (no auth required)

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/account/login` | Customer login page | `Storefront\Account\Auth\Login` |
| GET | `/account/register` | Customer registration page | `Storefront\Account\Auth\Register` |
| GET | `/forgot-password` | Forgot password page | `Storefront\Account\Auth\ForgotPassword` |
| POST | `/forgot-password` | Send reset link | Handled by Livewire action |
| GET | `/reset-password/{token}` | Reset password form | `Storefront\Account\Auth\ResetPassword` |
| POST | `/reset-password` | Reset password | Handled by Livewire action |

---

### 1.4 Checkout Routes

**Middleware:** `web`, `store.resolve`

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\Checkout\Show` |
| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\Checkout\Confirmation` |

---

## 2. Storefront REST API

**Prefix:** `/api/storefront/v1`
**Middleware:** `store.resolve`, `throttle:api.storefront`
**Content-Type:** `application/json`

These endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).

---

### 2.1 Cart Endpoints

#### POST /api/storefront/v1/carts

Create a new cart.

- **Authentication:** None (guest) or session-based (logged-in customer)
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "currency": "EUR"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |

**Success response:** `201 Created`

```json
{
  "id": 42,
  "store_id": 1,
  "customer_id": null,
  "currency": "EUR",
  "cart_version": 1,
  "status": "active",
  "lines": [],
  "totals": {
    "subtotal": 0,


output:
Chunk ID: dbafd3
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


output:
Chunk ID: ad3d07
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2542
Output:
# API and Routing Specification

This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.

All monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).

---

## 1. Web Routes (Blade/Livewire Pages)

### 1.1 Admin Auth Routes

No middleware beyond the default `web` group. These routes handle admin login and logout.

| Method | Path | Description | Component / Action |
|--------|------|-------------|--------------------|
| GET | `/admin/login` | Admin login page | `Livewire: Admin\Auth\Login` |
| POST | `/admin/login` | Process admin login | Handled by Livewire action |
| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |
| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\Auth\ForgotPassword` |
| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |
| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\Auth\ResetPassword` |
| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |

Rate limit: `login` tier (5 per minute per IP).

---

### 1.2 Admin Routes

**Prefix:** `/admin`
**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`

All admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).

> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.

#### Dashboard

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\Dashboard` |

#### Products

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\Products\Index` |
| GET | `/admin/products/create` | Create product form | `Admin\Products\Create` |
| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |

#### Inventory

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/inventory` | Inventory management page | `Admin\Inventory\Index` |

#### Collections

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/collections` | Collection list | `Admin\Collections\Index` |
| GET | `/admin/collections/create` | Create collection form | `Admin\Collections\Create` |
| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\Collections\Edit` |

#### Orders

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/orders` | Order list with status filters | `Admin\Orders\Index` |
| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |

#### Customers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/customers` | Customer list | `Admin\Customers\Index` |
| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |

#### Discounts

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/discounts` | Discount list | `Admin\Discounts\Index` |
| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\Discounts\Create` |
| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\Discounts\Edit` |

#### Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\Settings\Index` |
| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\Settings\Shipping` |
| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\Settings\Taxes` |

#### Themes and Online Store

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\Themes\Index` |
| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\Themes\Editor` |

#### Pages

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/pages` | Content pages list | `Admin\Pages\Index` |
| GET | `/admin/pages/create` | Create content page | `Admin\Pages\Create` |
| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\Pages\Edit` |

#### Navigation

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/navigation` | Navigation menus management | `Admin\Navigation\Index` |

#### Apps

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\Apps\Index` |
| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\Apps\Show` |

#### Developers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |

> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.

#### Analytics

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |

#### Search Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\Search\Settings` |

---

### 1.3 Storefront Routes

**Middleware:** `web`, `store.resolve`

These routes serve the public-facing storefront using Blade templates rendered through the active theme engine.

| Method | Path | Description | Component / View |
|--------|------|-------------|------------------|
| GET | `/` | Home page | `Storefront\Home` |
| GET | `/collections` | All collections listing | `Storefront\Collections\Index` |
| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\Collections\Show` |
| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\Products\Show` |
| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\Cart\Show` |
| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\Search\Index` |
| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\Pages\Show` |

#### Customer Account Routes

**Additional middleware:** `auth:customer`

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/account` | Customer account overview | `Storefront\Account\Dashboard` |
| GET | `/account/orders` | Order history list | `Storefront\Account\Orders\Index` |
| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\Account\Orders\Show` |
| GET | `/account/addresses` | Address book management | `Storefront\Account\Addresses\Index` |

#### Customer Auth Routes (no auth required)

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/account/login` | Customer login page | `Storefront\Account\Auth\Login` |
| GET | `/account/register` | Customer registration page | `Storefront\Account\Auth\Register` |
| GET | `/forgot-password` | Forgot password page | `Storefront\Account\Auth\ForgotPassword` |
| POST | `/forgot-password` | Send reset link | Handled by Livewire action |
| GET | `/reset-password/{token}` | Reset password form | `Storefront\Account\Auth\ResetPassword` |
| POST | `/reset-password` | Reset password | Handled by Livewire action |

---

### 1.4 Checkout Routes

**Middleware:** `web`, `store.resolve`

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\Checkout\Show` |
| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\Checkout\Confirmation` |

---

## 2. Storefront REST API

**Prefix:** `/api/storefront/v1`
**Middleware:** `store.resolve`, `throttle:api.storefront`
**Content-Type:** `application/json`

These endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).

---

### 2.1 Cart Endpoints

#### POST /api/storefront/v1/carts

Create a new cart.

- **Authentication:** None (guest) or session-based (logged-in customer)
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "currency": "EUR"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `currency` | string | No | Valid ISO 4217 code. Defaults to store's `default_currency`. |

**Success response:** `201 Created`

```json
{
  "id": 42,
  "store_id": 1,
  "customer_id": null,
  "currency": "EUR",
  "cart_version": 1,
  "status": "active",
  "lines": [],
  "totals": {
    "subtotal": 0,