stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminCollectionApiTest.php

content:
<?php

use App\Models\Collection;
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;

uses(RefreshDatabase::class);

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

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

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

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

test('admin collection api lists creates updates and deletes collections', function (): void {
    $store = adminCollectionApiStore();
    $products = Product::factory()
        ->count(3)
        ->withDefaultVariant()
        ->create(['store_id' => $store->getKey()]);
    Collection::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
        'title' => 'Other Store Collection',
    ]);
    $user = adminCollectionApiUser();

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=New")
        ->assertOk()
        ->assertJsonMissing(['title' => 'Other Store Collection']);

    $createResponse = $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
            'title' => 'API Winter',
            'description_html' => '
Cold weather goods.
',
            'type' => 'manual',
            'status' => 'active',
            'product_ids' => $products->take(2)->pluck('id')->all(),
        ])
        ->assertCreated()
        ->assertJsonPath('data.title', 'API Winter')
        ->assertJsonPath('data.handle', 'api-winter')
        ->assertJsonPath('data.products_count', 2);

    $collectionId = $createResponse->json('data.id');
    $collection = Collection::withoutGlobalScopes()->findOrFail($collectionId);

    expect($collection->products()->pluck('collection_products.position', 'products.id')->all())
        ->toBe([
            $products[0]->getKey() => 0,
            $products[1]->getKey() => 1,
        ]);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}", [
            'title' => 'API Winter Edit',
            'status' => 'draft',
            'add_product_ids' => [$products[2]->getKey()],
            'remove_product_ids' => [$products[0]->getKey()],
        ])
        ->assertOk()
        ->assertJsonPath('data.title', 'API Winter Edit')
        ->assertJsonPath('data.status', 'draft')
        ->assertJsonPath('data.products_count', 2);

    expect($collection->refresh()->products()->pluck('products.id')->all())
        ->toBe([$products[1]->getKey(), $products[2]->getKey()]);

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

    expect(Collection::withoutGlobalScopes()->whereKey($collection->getKey())->exists())->toBeFalse();
});

test('admin collection api enforces token abilities and store scope', function (): void {
    $store = adminCollectionApiStore();
    $otherStore = Store::factory()->create();
    $collection = Collection::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'Token Collection',
    ]);
    $readToken = adminCollectionApiToken($store, ['read-collections']);
    $writeToken = adminCollectionApiToken($store, ['write-collections']);
    $otherStoreToken = adminCollectionApiToken($otherStore, ['read-collections']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=Token")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Token Collection');

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

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

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

test('admin collection api validates handles and product store ownership', function (): void {
    $store = adminCollectionApiStore();
    $existing = Collection::factory()->create([
        'store_id' => $store->getKey(),
        'handle' => 'existing-api-collection',
    ]);
    $otherStoreProduct = Product::factory()
        ->withDefaultVariant()
        ->create(['store_id' => Store::factory()->create()->getKey()]);

    $this->actingAs(adminCollectionApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
            'title' => 'Invalid API Collection',
            'handle' => $existing->handle,
            'type' => 'manual',
            'product_ids' => [$otherStoreProduct->getKey()],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['handle', 'product_ids.0']);
});


output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminCollectionApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/CollectionController.php
@@
-    public function store(Request $request, Store $store): CollectionResource
+    public function store(Request $request, Store $store): JsonResponse
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/CollectionController.php

type: update

unified_diff:
@@ -43,3 +43,3 @@

-    public function store(Request $request, Store $store): CollectionResource
+    public function store(Request $request, Store $store): JsonResponse
     {

status: completed


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/CollectionController.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


cmd: php artisan test --compact tests/Feature/Api/AdminCollectionApiTest.php


output:
Chunk ID: 9ab816
Wall time: 0.3418 seconds
Process exited with code 0
Original token count: 5
Output:
{"result":"pass"}


command:
/bin/zsh
-lc
vendor/bin/pint --dirty --format agent

type: unknown

cmd: vendor/bin/pint --dirty --format agent

result: pass


output:
Chunk ID: e61b18
Wall time: 1.5547 seconds
Process exited with code 0
Original token count: 16
Output:

  ...

  Tests:    3 passed (24 assertions)
  Duration: 1.34s


command: php artisan test --compact tests/Feature/Api/AdminCollectionApiTest.php

aggregated_output:
  ...

  Tests:    3 passed (24 assertions)
  Duration: 1.34s


message: The collection API tests pass. I’m running all API tests next because this touches the shared admin route prefix and token ability middleware.

phase: commentary


type: message

role: assistant

text: The collection API tests pass. I’m running all API tests next because this touches the shared admin route prefix and token ability middleware.


cmd: php artisan test --compact tests/Feature/Api


output:
Chunk ID: dd93aa
Wall time: 9.1295 seconds
Process exited with code 0
Original token count: 23
Output:

  ............................

  Tests:    28 passed (218 assertions)
  Duration: 9.06s


command: php artisan test --compact tests/Feature/Api


cmd: php artisan route:list --path=api/admin/v1/stores --except-vendor


output:
Chunk ID: 4b2f7f
Wall time: 0.0282 seconds
Process exited with code 0
Original token count: 264
Output:

  GET|HEAD  api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  POST      api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  PUT       api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  DELETE    api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  GET|HEAD  api/admin/v1/stores/{store}/customers api.admin.v1.customers.inde…
  GET|HEAD  api/admin/v1/stores/{store}/customers/{customer} api.admin.v1.cus…
  GET|HEAD  api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › Ap…
  GET|HEAD  api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.sh…
  POST      api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admin…
  POST      api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.o…
  GET|HEAD  api/admin/v1/stores/{store}/products api.admin.v1.products.index …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…

                                                           Showing [12] routes


command: php artisan route:list --path=api/admin/v1/stores --except-vendor

aggregated_output:
  GET|HEAD  api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  POST      api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  PUT       api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  DELETE    api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  GET|HEAD  api/admin/v1/stores/{store}/customers api.admin.v1.customers.inde…
  GET|HEAD  api/admin/v1/stores/{store}/customers/{customer} api.admin.v1.cus…
  GET|HEAD  api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › Ap…
  GET|HEAD  api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.sh…
  POST      api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admin…
  POST      api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.o…
  GET|HEAD  api/admin/v1/stores/{store}/products api.admin.v1.products.index …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…

                                                           Showing [12] routes


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
-| 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, 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 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, 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. |
@@
 - 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the browser smoke test changes.
 - 2026-05-04: `php artisan test --compact tests/Browser/SmokeTest.php` passed after adding automated browser smoke coverage: 3 tests, 26 assertions.
 - 2026-05-04: `php artisan test --compact` passed with the Browser suite included in the default run after adding automated browser smoke coverage: 212 tests, 1151 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 API resource controller, route group middleware, scoped exists validation, and JSON API testing docs plus Pest 4 docs before the admin collection API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/CollectionController --no-interaction`, `php artisan make:resource Admin/V1/CollectionResource --no-interaction`, and `php artisan make:test Api/AdminCollectionApiTest --pest --no-interaction` created the collection API controller/resource and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin collection API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCollectionApiTest.php` passed after the admin collection API changes: 3 tests, 24 assertions.
+- 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.
@@
-- 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 plus 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, and order fulfillment/refund mutations.
@@
-- Non-order admin REST write endpoints and the larger admin REST surfaces for collections, 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.
+- 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.
@@
-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/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/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.
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/specs/progress.md

unified_diff:
@@ -29,3 +29,3 @@
 | 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, 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 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, 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. |
 | 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. |
@@ -333,2 +333,8 @@
 - 2026-05-04: `php artisan test --compact` passed with the Browser suite included in the default run after adding automated browser smoke coverage: 212 tests, 1151 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 API resource controller, route group middleware, scoped exists validation, and JSON API testing docs plus Pest 4 docs before the admin collection API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/CollectionController --no-interaction`, `php artisan make:resource Admin/V1/CollectionResource --no-interaction`, and `php artisan make:test Api/AdminCollectionApiTest --pest --no-interaction` created the collection API controller/resource and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin collection API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCollectionApiTest.php` passed after the admin collection API changes: 3 tests, 24 assertions.
+- 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.

@@ -366,3 +372,3 @@
 - 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 plus 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, 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.
@@ -397,3 +403,3 @@
 - `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 and the larger admin REST surfaces for collections, 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.
+- 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.
 - Full automated browser suites from Spec 08 are still incomplete beyond the initial Pest browser smoke coverage for storefront, admin, and mobile rendering.
@@ -403,2 +409,2 @@

-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/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/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: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: Collection API routes and tests are clean, and progress is updated. I’m running the full suite with the Browser suite included before committing.


text: Collection API routes and tests are clean, and progress is updated. I’m running the full suite with the Browser suite included before committing.


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

cmd: git diff --check


output:
Chunk ID: aa50f0
Wall time: 30.0004 seconds
Process running with session ID 42489
Original token count: 29
Output:

  ............................................................................
  ...............................


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


output:
Chunk ID: a100aa
Wall time: 2.5558 seconds
Process exited with code 0
Original token count: 44
Output:
.............................................
  ...............................................................

  Tests:    215 passed (1175 assertions)
  Duration: 50.55s


command:
/bin/zsh
-lc
php artisan test --compact

cmd: php artisan test --compact


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCollectionApiTest.php` passed after the admin collection API changes: 3 tests, 24 assertions.
 - 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.
*** End Patch


unified_diff:
@@ -339,2 +339,3 @@
 - 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.


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


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 status --short

cmd: git status --short

aggregated_output:
 M routes/api.php
 M specs/progress.md
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T05-58-46-727Z.log
?? app/Http/Controllers/Api/Admin/V1/CollectionController.php
?? app/Http/Resources/Admin/V1/CollectionResource.php
?? tests/Feature/Api/AdminCollectionApiTest.php


output:
Chunk ID: cca80d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 93
Output:
 M routes/api.php
 M specs/progress.md
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T05-58-46-727Z.log
?? app/Http/Controllers/Api/Admin/V1/CollectionController.php
?? app/Http/Resources/Admin/V1/CollectionResource.php
?? tests/Feature/Api/AdminCollectionApiTest.php


output:
Chunk ID: e92e8a
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(-)


output:
Chunk ID: 1a7216
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

aggregated_output:
Removing .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
Removing .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
Removing .playwright-mcp/console-2026-05-04T05-58-46-727Z.log


output:
Chunk ID: 9f9c1a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 47
Output:
Removing .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
Removing .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
Removing .playwright-mcp/console-2026-05-04T05-58-46-727Z.log


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


cmd: sed -n '1,220p' tests/Feature/Api/AdminCollectionApiTest.php


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


command: sed -n '1,220p' tests/Feature/Api/AdminCollectionApiTest.php

AdminCollectionApiTest.php

type: read

aggregated_output:
<?php

use App\Models\Collection;
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;

uses(RefreshDatabase::class);

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

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

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

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

test('admin collection api lists creates updates and deletes collections', function (): void {
    $store = adminCollectionApiStore();
    $products = Product::factory()
        ->count(3)
        ->withDefaultVariant()
        ->create(['store_id' => $store->getKey()]);
    Collection::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
        'title' => 'Other Store Collection',
    ]);
    $user = adminCollectionApiUser();

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=New")
        ->assertOk()
        ->assertJsonMissing(['title' => 'Other Store Collection']);

    $createResponse = $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
            'title' => 'API Winter',
            'description_html' => '
Cold weather goods.
',
            'type' => 'manual',
            'status' => 'active',
            'product_ids' => $products->take(2)->pluck('id')->all(),
        ])
        ->assertCreated()
        ->assertJsonPath('data.title', 'API Winter')
        ->assertJsonPath('data.handle', 'api-winter')
        ->assertJsonPath('data.products_count', 2);

    $collectionId = $createResponse->json('data.id');
    $collection = Collection::withoutGlobalScopes()->findOrFail($collectionId);

    expect($collection->products()->pluck('collection_products.position', 'products.id')->all())
        ->toBe([
            $products[0]->getKey() => 0,
            $products[1]->getKey() => 1,
        ]);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}", [
            'title' => 'API Winter Edit',
            'status' => 'draft',
            'add_product_ids' => [$products[2]->getKey()],
            'remove_product_ids' => [$products[0]->getKey()],
        ])
        ->assertOk()
        ->assertJsonPath('data.title', 'API Winter Edit')
        ->assertJsonPath('data.status', 'draft')
        ->assertJsonPath('data.products_count', 2);

    expect($collection->refresh()->products()->pluck('products.id')->all())
        ->toBe([$products[1]->getKey(), $products[2]->getKey()]);

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

    expect(Collection::withoutGlobalScopes()->whereKey($collection->getKey())->exists())->toBeFalse();
});

test('admin collection api enforces token abilities and store scope', function (): void {
    $store = adminCollectionApiStore();
    $otherStore = Store::factory()->create();
    $collection = Collection::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'Token Collection',
    ]);
    $readToken = adminCollectionApiToken($store, ['read-collections']);
    $writeToken = adminCollectionApiToken($store, ['write-collections']);
    $otherStoreToken = adminCollectionApiToken($otherStore, ['read-collections']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=Token")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Token Collection');

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

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

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

test('admin collection api validates handles and product store ownership', function (): void {
    $store = adminCollectionApiStore();
    $existing = Collection::factory()->create([
        'store_id' => $store->getKey(),
        'handle' => 'existing-api-collection',
    ]);
    $otherStoreProduct = Product::factory()
        ->withDefaultVariant()
        ->create(['store_id' => Store::factory()->create()->getKey()]);

    $this->actingAs(adminCollectionApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
            'title' => 'Invalid API Collection',
            'handle' => $existing->handle,
            'type' => 'manual',
            'product_ids' => [$otherStoreProduct->getKey()],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['handle', 'product_ids.0']);
});


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

aggregated_output:
<?php

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

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\CollectionResource;
use App\Models\Collection;
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\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;

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

        $validated = $request->validate([
            'status' => ['nullable', Rule::in(['draft', 'active', 'archived'])],
            'query' => ['nullable', 'string', 'max:255'],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $collections = Collection::withoutGlobalScopes()
            ->withCount('products')
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status))
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $query->where('title', 'like', '%'.$search.'%');
            })
            ->latest('updated_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return CollectionResource::collection($collections);
    }

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

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

        $collection = DB::transaction(function () use ($store, $validated): Collection {
            $collection = Collection::withoutGlobalScopes()->create([
                'store_id' => $store->getKey(),
                ...$this->attributesForCreate($validated),
            ]);

            if (array_key_exists('product_ids', $validated)) {
                $this->replaceProducts($collection, $validated['product_ids']);
            }

            return $collection;
        });

        return CollectionResource::make($this->loadCollection($collection))
            ->response()
            ->setStatusCode(201);
    }

    public function update(Request $request, Store $store, Collection $collection): CollectionResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessCollectionBelongsToStore($collection, $store);

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

        DB::transaction(function () use ($collection, $validated): void {
            $attributes = $this->attributesForUpdate($validated);

            if ($attributes !== []) {
                $collection->update($attributes);
            }

            if (array_key_exists('product_ids', $validated)) {
                $this->replaceProducts($collection, $validated['product_ids']);
            } else {
                $this->addProducts($collection, $validated['add_product_ids'] ?? []);
                $this->removeProducts($collection, $validated['remove_product_ids'] ?? []);
            }
        });

        return CollectionResource::make($this->loadCollection($collection->refresh()));
    }

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

        $collection->delete();

        return response()->json(['message' => 'Collection 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 abortUnlessCollectionBelongsToStore(Collection $collection, Store $store): void
    {
        abort_unless((int) $collection->store_id === $store->getKey(), 404);
    }

    /**
     * @return array<string, mixed>
     */
    private function validatePayload(Request $request, Store $store, ?Collection $collection = null): array
    {
        $creating = $collection === null;

        return $request->validate([
            'title' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'],
            'handle' => [
                'sometimes',
                'nullable',
                'string',
                'max:255',
                Rule::unique('collections', 'handle')
                    ->where('store_id', $store->getKey())
                    ->ignore($collection?->getKey()),
            ],
            'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'],
            'type' => [$creating ? 'required' : 'sometimes', Rule::in(['manual', 'automated'])],
            'status' => ['sometimes', Rule::in(['draft', 'active', 'archived'])],
            'product_ids' => ['sometimes', 'array'],
            'product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
            'add_product_ids' => ['sometimes', 'array'],
            'add_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
            'remove_product_ids' => ['sometimes', 'array'],
            'remove_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
        ]);
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForCreate(array $validated): array
    {
        $title = (string) $validated['title'];
        $handle = filled($validated['handle'] ?? null) ? (string) $validated['handle'] : $title;

        return [
            'title' => $title,
            'handle' => Str::slug($handle),
            'description_html' => $validated['description_html'] ?? null,
            'type' => $validated['type'],
            'status' => $validated['status'] ?? 'active',
        ];
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForUpdate(array $validated): array
    {
        $attributes = Arr::only($validated, ['title', 'description_html', 'type', 'status']);

        if (array_key_exists('handle', $validated) && filled($validated['handle'])) {
            $attributes['handle'] = Str::slug((string) $validated['handle']);
        }

        return $attributes;
    }

    /**
     * @param  array<int, int>  $productIds
     */
    private function replaceProducts(Collection $collection, array $productIds): void
    {
        $collection->products()->sync($this->positionedProductIds($productIds));
    }

    /**
     * @param  array<int, int>  $productIds
     */
    private function addProducts(Collection $collection, array $productIds): void
    {
        if ($productIds === []) {
            return;
        }

        $startPosition = (int) $collection->products()->max('collection_products.position') + 1;

        $collection->products()->syncWithoutDetaching($this->positionedProductIds($productIds, $startPosition));
    }

    /**
     * @param  array<int, int>  $productIds
     */
    private function removeProducts(Collection $collection, array $productIds): void
    {
        if ($productIds === []) {
            return;
        }

        $collection->products()->detach($productIds);
    }

    /**
     * @param  array<int, int>  $productIds
     * @return array<int, array{position: int}>
     */
    private function positionedProductIds(array $productIds, int $startPosition = 0): array
    {
        return collect($productIds)
            ->unique()
            ->values()
            ->mapWithKeys(fn (int $productId, int $position): array => [$productId => ['position' => $startPosition + $position]])
            ->all();
    }

    private function loadCollection(Collection $collection): Collection
    {
        return $collection->load('products')->loadCount('products');
    }
}


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

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

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\CollectionResource;
use App\Models\Collection;
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\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;

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

        $validated = $request->validate([
            'status' => ['nullable', Rule::in(['draft', 'active', 'archived'])],
            'query' => ['nullable', 'string', 'max:255'],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $collections = Collection::withoutGlobalScopes()
            ->withCount('products')
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status))
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $query->where('title', 'like', '%'.$search.'%');
            })
            ->latest('updated_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return CollectionResource::collection($collections);
    }

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

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

        $collection = DB::transaction(function () use ($store, $validated): Collection {
            $collection = Collection::withoutGlobalScopes()->create([
                'store_id' => $store->getKey(),
                ...$this->attributesForCreate($validated),
            ]);

            if (array_key_exists('product_ids', $validated)) {
                $this->replaceProducts($collection, $validated['product_ids']);
            }

            return $collection;
        });

        return CollectionResource::make($this->loadCollection($collection))
            ->response()
            ->setStatusCode(201);
    }

    public function update(Request $request, Store $store, Collection $collection): CollectionResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessCollectionBelongsToStore($collection, $store);

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

        DB::transaction(function () use ($collection, $validated): void {
            $attributes = $this->attributesForUpdate($validated);

            if ($attributes !== []) {
                $collection->update($attributes);
            }

            if (array_key_exists('product_ids', $validated)) {
                $this->replaceProducts($collection, $validated['product_ids']);
            } else {
                $this->addProducts($collection, $validated['add_product_ids'] ?? []);
                $this->removeProducts($collection, $validated['remove_product_ids'] ?? []);
            }
        });

        return CollectionResource::make($this->loadCollection($collection->refresh()));
    }

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

        $collection->delete();

        return response()->json(['message' => 'Collection 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 abortUnlessCollectionBelongsToStore(Collection $collection, Store $store): void
    {
        abort_unless((int) $collection->store_id === $store->getKey(), 404);
    }

    /**
     * @return array<string, mixed>
     */
    private function validatePayload(Request $request, Store $store, ?Collection $collection = null): array
    {
        $creating = $collection === null;

        return $request->validate([
            'title' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'],
            'handle' => [
                'sometimes',
                'nullable',
                'string',
                'max:255',
                Rule::unique('collections', 'handle')
                    ->where('store_id', $store->getKey())
                    ->ignore($collection?->getKey()),
            ],
            'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'],
            'type' => [$creating ? 'required' : 'sometimes', Rule::in(['manual', 'automated'])],
            'status' => ['sometimes', Rule::in(['draft', 'active', 'archived'])],
            'product_ids' => ['sometimes', 'array'],
            'product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
            'add_product_ids' => ['sometimes', 'array'],
            'add_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
            'remove_product_ids' => ['sometimes', 'array'],
            'remove_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
        ]);
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForCreate(array $validated): array
    {
        $title = (string) $validated['title'];
        $handle = filled($validated['handle'] ?? null) ? (string) $validated['handle'] : $title;

        return [
            'title' => $title,
            'handle' => Str::slug($handle),
            'description_html' => $validated['description_html'] ?? null,
            'type' => $validated['type'],
            'status' => $validated['status'] ?? 'active',
        ];
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForUpdate(array $validated): array
    {
        $attributes = Arr::only($validated, ['title', 'description_html', 'type', 'status']);

        if (array_key_exists('handle', $validated) && filled($validated['handle'])) {
            $attributes['handle'] = Str::slug((string) $validated['handle']);
        }

        return $attributes;
    }

    /**
     * @param  array<int, int>  $productIds
     */
    private function replaceProducts(Collection $collection, array $productIds): void
    {
        $collection->products()->sync($this->positionedProductIds($productIds));
    }

    /**
     * @param  array<int, int>  $productIds
     */
    private function addProducts(Collection $collection, array $productIds): void
    {
        if ($productIds === []) {
            return;
        }

        $startPosition = (int) $collection->products()->max('collection_products.position') + 1;

        $collection->products()->syncWithoutDetaching($this->positionedProductIds($productIds, $startPosition));
    }

    /**
     * @param  array<int, int>  $productIds
     */
    private function removeProducts(Collection $collection, array $productIds): void
    {
        if ($productIds === []) {
            return;
        }

        $collection->products()->detach($productIds);
    }

    /**
     * @param  array<int, int>  $productIds
     * @return array<int, array{position: int}>
     */
    private function positionedProductIds(array $productIds, int $startPosition = 0): array
    {
        return collect($productIds)
            ->unique()
            ->values()
            ->mapWithKeys(fn (int $productId, int $position): array => [$productId => ['position' => $startPosition + $position]])
            ->all();
    }

    private function loadCollection(Collection $collection): Collection
    {
        return $collection->load('products')->loadCount('products');
    }
}


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

use App\Models\Collection;
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;

uses(RefreshDatabase::class);

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

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

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

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

test('admin collection api lists creates updates and deletes collections', function (): void {
    $store = adminCollectionApiStore();
    $products = Product::factory()
        ->count(3)
        ->withDefaultVariant()
        ->create(['store_id' => $store->getKey()]);
    Collection::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
        'title' => 'Other Store Collection',
    ]);
    $user = adminCollectionApiUser();

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=New")
        ->assertOk()
        ->assertJsonMissing(['title' => 'Other Store Collection']);

    $createResponse = $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
            'title' => 'API Winter',
            'description_html' => '
Cold weather goods.
',
            'type' => 'manual',
            'status' => 'active',
            'product_ids' => $products->take(2)->pluck('id')->all(),
        ])
        ->assertCreated()
        ->assertJsonPath('data.title', 'API Winter')
        ->assertJsonPath('data.handle', 'api-winter')
        ->assertJsonPath('data.products_count', 2);

    $collectionId = $createResponse->json('data.id');
    $collection = Collection::withoutGlobalScopes()->findOrFail($collectionId);

    expect($collection->products()->pluck('collection_products.position', 'products.id')->all())
        ->toBe([
            $products[0]->getKey() => 0,
            $products[1]->getKey() => 1,
        ]);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}", [
            'title' => 'API Winter Edit',
            'status' => 'draft',
            'add_product_ids' => [$products[2]->getKey()],
            'remove_product_ids' => [$products[0]->getKey()],
        ])
        ->assertOk()
        ->assertJsonPath('data.title', 'API Winter Edit')
        ->assertJsonPath('data.status', 'draft')
        ->assertJsonPath('data.products_count', 2);

    expect($collection->refresh()->products()->pluck('products.id')->all())
        ->toBe([$products[1]->getKey(), $products[2]->getKey()]);

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

    expect(Collection::withoutGlobalScopes()->whereKey($collection->getKey())->exists())->toBeFalse();
});

test('admin collection api enforces token abilities and store scope', function (): void {
    $store = adminCollectionApiStore();
    $otherStore = Store::factory()->create();
    $collection = Collection::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'Token Collection',
    ]);
    $readToken = adminCollectionApiToken($store, ['read-collections']);
    $writeToken = adminCollectionApiToken($store, ['write-collections']);
    $otherStoreToken = adminCollectionApiToken($otherStore, ['read-collections']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=Token")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Token Collection');

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

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

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

test('admin collection api validates handles and product store ownership', function (): void {
    $store = adminCollectionApiStore();
    $existing = Collection::factory()->create([
        'store_id' => $store->getKey(),
        'handle' => 'existing-api-collection',
    ]);
    $otherStoreProduct = Product::factory()
        ->withDefaultVariant()
        ->create(['store_id' => Store::factory()->create()->getKey()]);

    $this->actingAs(adminCollectionApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
            'title' => 'Invalid API Collection',
            'handle' => $existing->handle,
            'type' => 'manual',
            'product_ids' => [$otherStoreProduct->getKey()],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['handle', 'product_ids.0']);
});


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

aggregated_output:
<?php

namespace App\Http\Resources\Admin\V1;

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

class CollectionResource 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,
            'title' => $this->title,
            'handle' => $this->handle,
            'description_html' => $this->description_html,
            'type' => $this->type?->value,
            'status' => $this->status?->value,
            'products_count' => $this->whenCounted('products'),
            'products' => $this->whenLoaded('products', fn () => $this->products->map(fn ($product): array => [
                'id' => $product->id,
                'title' => $product->title,
                'handle' => $product->handle,
                'position' => $product->pivot?->position,
            ])->values()),
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}


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

namespace App\Http\Resources\Admin\V1;

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

class CollectionResource 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,
            'title' => $this->title,
            'handle' => $this->handle,
            'description_html' => $this->description_html,
            'type' => $this->type?->value,
            'status' => $this->status?->value,
            'products_count' => $this->whenCounted('products'),
            'products' => $this->whenLoaded('products', fn () => $this->products->map(fn ($product): array => [
                'id' => $product->id,
                'title' => $product->title,
                'handle' => $product->handle,
                'position' => $product->pivot?->position,
            ])->values()),
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}


cmd: rg -n "collection_products|collections" database/migrations | head -n 80


command: rg -n "collection_products|collections" database/migrations | head -n 80

type: search

cmd: rg -n 'collection_products|collections' database/migrations

path: migrations

aggregated_output:
database/migrations/2026_05_03_213903_create_collection_products_table.php:14:        Schema::create('collection_products', function (Blueprint $table) {
database/migrations/2026_05_03_213903_create_collection_products_table.php:30:        Schema::dropIfExists('collection_products');
database/migrations/2026_05_03_213902_create_collections_table.php:14:        Schema::create('collections', function (Blueprint $table) {
database/migrations/2026_05_03_213902_create_collections_table.php:35:        Schema::dropIfExists('collections');


output:
Chunk ID: 0e76bf
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 135
Output:
database/migrations/2026_05_03_213903_create_collection_products_table.php:14:        Schema::create('collection_products', function (Blueprint $table) {
database/migrations/2026_05_03_213903_create_collection_products_table.php:30:        Schema::dropIfExists('collection_products');
database/migrations/2026_05_03_213902_create_collections_table.php:14:        Schema::create('collections', function (Blueprint $table) {
database/migrations/2026_05_03_213902_create_collections_table.php:35:        Schema::dropIfExists('collections');


cmd: sed -n '1,90p' database/migrations/2026_05_03_213903_create_collection_products_table.php


command: sed -n '1,90p' database/migrations/2026_05_03_213903_create_collection_products_table.php

aggregated_output:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('collection_products', function (Blueprint $table) {
            $table->foreignId('collection_id')->constrained()->cascadeOnDelete();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->unsignedInteger('position')->default(0);

            $table->primary(['collection_id', 'product_id']);
            $table->index('product_id');
            $table->index(['collection_id', 'position']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('collection_products');
    }
};


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

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('collection_products', function (Blueprint $table) {
            $table->foreignId('collection_id')->constrained()->cascadeOnDelete();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->unsignedInteger('position')->default(0);

            $table->primary(['collection_id', 'product_id']);
            $table->index('product_id');
            $table->index(['collection_id', 'position']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('collection_products');
    }
};


aggregated_output:
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/CollectionController.php
?? app/Http/Resources/Admin/V1/CollectionResource.php
?? tests/Feature/Api/AdminCollectionApiTest.php


output:
Chunk ID: 2e8bbc
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 51
Output:
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/CollectionController.php
?? app/Http/Resources/Admin/V1/CollectionResource.php
?? tests/Feature/Api/AdminCollectionApiTest.php


cmd: git add app/Http/Controllers/Api/Admin/V1/CollectionController.php app/Http/Resources/Admin/V1/CollectionResource.php routes/api.php specs/progress.md tests/Feature/Api/AdminCollectionApiTest.php && git diff --cached --stat


command: git add app/Http/Controllers/Api/Admin/V1/CollectionController.php app/Http/Resources/Admin/V1/CollectionResource.php routes/api.php specs/progress.md tests/Feature/Api/AdminCollectionApiTest.php && git diff --cached --stat

aggregated_output:
 .../Api/Admin/V1/CollectionController.php          | 231 +++++++++++++++++++++
 app/Http/Resources/Admin/V1/CollectionResource.php |  36 ++++
 routes/api.php                                     |  11 +
 specs/progress.md                                  |  15 +-
 tests/Feature/Api/AdminCollectionApiTest.php       | 147 +++++++++++++
 5 files changed, 436 insertions(+), 4 deletions(-)


output:
Chunk ID: 8f28d9
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 97
Output:
 .../Api/Admin/V1/CollectionController.php          | 231 +++++++++++++++++++++
 app/Http/Resources/Admin/V1/CollectionResource.php |  36 ++++
 routes/api.php                                     |  11 +
 specs/progress.md                                  |  15 +-
 tests/Feature/Api/AdminCollectionApiTest.php       | 147 +++++++++++++
 5 files changed, 436 insertions(+), 4 deletions(-)


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


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

aggregated_output:
[2026-05-04-codex-5-5-goal d480aec6] Add admin collection API endpoints
 5 files changed, 436 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Controllers/Api/Admin/V1/CollectionController.php
 create mode 100644 app/Http/Resources/Admin/V1/CollectionResource.php
 create mode 100644 tests/Feature/Api/AdminCollectionApiTest.php


output:
Chunk ID: a6c0cf
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 85
Output:
[2026-05-04-codex-5-5-goal d480aec6] Add admin collection API endpoints
 5 files changed, 436 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Controllers/Api/Admin/V1/CollectionController.php
 create mode 100644 app/Http/Resources/Admin/V1/CollectionResource.php
 create mode 100644 tests/Feature/Api/AdminCollectionApiTest.php


message: Collection API is committed as `d480aec6`. I’m continuing through the remaining admin REST gap; the next bounded surface is discounts, since the admin UI/services already encode most of the behavior.


text: Collection API is committed as `d480aec6`. I’m continuing through the remaining admin REST gap; the next bounded surface is discounts, since the admin UI/services already encode most of the behavior.


cmd: sed -n '2136,2265p' specs/02-API-ROUTES.md


cmd: sed -n '1,260p' app/Models/Discount.php && sed -n '1,220p' database/factories/DiscountFactory.php


cmd: sed -n '1,280p' app/Livewire/Admin/Discounts/Form.php


command: sed -n '2136,2265p' specs/02-API-ROUTES.md

aggregated_output:
| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/discounts

List discounts.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `type` | string | No | Filter: `code`, `automatic`. |
| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |
| `page` | integer | No | Default: 1. |
| `per_page` | integer | No | Default: 25, max: 100. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 20,
      "store_id": 1,
      "type": "code",
      "code": "WELCOME10",
      "value_type": "percent",
      "value_amount": 10,
      "starts_at": "2026-01-01T00:00:00Z",
      "ends_at": "2026-12-31T23:59:59Z",
      "usage_limit": 1000,
      "usage_count": 42,
      "rules_json": {
        "minimum_purchase_amount": 2000,
        "applicable_product_ids": [],
        "applicable_collection_ids": [],
        "customer_eligibility": "all"
      },
      "created_at": "2026-01-01T00:00:00Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 25,
    "total": 8,
    "last_page": 1
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/discounts

Create a discount.

**Request body:**

```json
{
  "type": "code",
  "code": "SUMMER20",
  "value_type": "percent",
  "value_amount": 20,
  "starts_at": "2026-06-01T00:00:00Z",
  "ends_at": "2026-08-31T23:59:59Z",
  "usage_limit": 500,
  "rules_json": {
    "minimum_purchase_amount": 5000,
    "applicable_product_ids": [],
    "applicable_collection_ids": [5],
    "customer_eligibility": "all",
    "once_per_customer": true
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `type` | string | Yes | One of: `code`, `automatic`. |
| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |
| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |
| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |
| `starts_at` | string (ISO 8601) | No | Must be a valid datetime. |
| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |
| `usage_limit` | integer | No | Min: 1. Null for unlimited. |
| `rules_json` | object | No | See rules fields below. |
| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |
| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |
| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |
| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |
| `rules_json.once_per_customer` | boolean | No | Default: false. |

**Success response:** `201 Created`

Returns the full discount resource.

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate code, invalid dates, etc.)

---

#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}

Update a discount. Supports partial updates.

Same fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Discount not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}

Delete a discount.

**Authentication:** Bearer token with `write-discounts` ability.

**Success response:** `200 OK`


output:
Chunk ID: bfbfa5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1039
Output:
| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/discounts

List discounts.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `type` | string | No | Filter: `code`, `automatic`. |
| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |
| `page` | integer | No | Default: 1. |
| `per_page` | integer | No | Default: 25, max: 100. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 20,
      "store_id": 1,
      "type": "code",
      "code": "WELCOME10",
      "value_type": "percent",
      "value_amount": 10,
      "starts_at": "2026-01-01T00:00:00Z",
      "ends_at": "2026-12-31T23:59:59Z",
      "usage_limit": 1000,
      "usage_count": 42,
      "rules_json": {
        "minimum_purchase_amount": 2000,
        "applicable_product_ids": [],
        "applicable_collection_ids": [],
        "customer_eligibility": "all"
      },
      "created_at": "2026-01-01T00:00:00Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 25,
    "total": 8,
    "last_page": 1
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/discounts

Create a discount.

**Request body:**

```json
{
  "type": "code",
  "code": "SUMMER20",
  "value_type": "percent",
  "value_amount": 20,
  "starts_at": "2026-06-01T00:00:00Z",
  "ends_at": "2026-08-31T23:59:59Z",
  "usage_limit": 500,
  "rules_json": {
    "minimum_purchase_amount": 5000,
    "applicable_product_ids": [],
    "applicable_collection_ids": [5],
    "customer_eligibility": "all",
    "once_per_customer": true
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `type` | string | Yes | One of: `code`, `automatic`. |
| `code` | string | Conditional | Required when type is `code`. Unique per store. Max: 50. |
| `value_type` | string | Yes | One of: `fixed`, `percent`, `free_shipping`. |
| `value_amount` | integer | Yes | For `percent`: whole number 1-100 (e.g. 10 = 10%). For `fixed`: min 1, in minor units. For `free_shipping`: ignored. |
| `starts_at` | string (ISO 8601) | No | Must be a valid datetime. |
| `ends_at` | string (ISO 8601) | No | Must be after starts_at. |
| `usage_limit` | integer | No | Min: 1. Null for unlimited. |
| `rules_json` | object | No | See rules fields below. |
| `rules_json.minimum_purchase_amount` | integer | No | Min: 0. In minor units. |
| `rules_json.applicable_product_ids` | array | No | Empty array means all products. |
| `rules_json.applicable_collection_ids` | array | No | Empty array means all collections. |
| `rules_json.customer_eligibility` | string | No | One of: `all`, `specific_customers`, `specific_segments`. Default: `all`. |
| `rules_json.once_per_customer` | boolean | No | Default: false. |

**Success response:** `201 Created`

Returns the full discount resource.

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate code, invalid dates, etc.)

---

#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}

Update a discount. Supports partial updates.

Same fields as POST. Only provided fields are updated. The `code` cannot be changed after creation.

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Discount not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}

Delete a discount.

**Authentication:** Bearer token with `write-discounts` ability.

**Success response:** `200 OK`


command: sed -n '1,280p' app/Livewire/Admin/Discounts/Form.php

aggregated_output:
<?php

namespace App\Livewire\Admin\Discounts;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Collection as ProductCollection;
use App\Models\Discount;
use App\Models\Product;
use App\Models\Store;
use App\Support\Money;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;

class Form extends Component
{
    use AuthorizesRequests;

    public ?Discount $discount = null;

    public string $type = 'code';

    public string $code = '';

    public string $valueType = 'percent';

    public string $valueAmount = '10';

    public string $minimumPurchaseAmount = '';

    /**
     * @var array<int, int>
     */
    public array $specificProductIds = [];

    /**
     * @var array<int, int>
     */
    public array $specificCollectionIds = [];

    public string $usageLimit = '';

    public bool $onePerCustomer = false;

    public string $startsAt = '';

    public string $endsAt = '';

    public bool $isActive = false;

    public string $productSearch = '';

    public string $collectionSearch = '';

    public string $storeCurrency = 'EUR';

    public function mount(?Discount $discount = null): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->storeCurrency = $store->default_currency;

        if ($discount?->exists) {
            abort_unless((int) $discount->store_id === $store->getKey(), 404);

            $this->authorize('update', $discount);

            $this->discount = $discount;
            $this->fillFromDiscount($discount);

            return;
        }

        $this->authorize('create', Discount::class);

        $this->startsAt = now()->format('Y-m-d\TH:i');
    }

    public function save(): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->authorizeSave();
        $this->normalizeCode();

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

                    $exists = Discount::withoutGlobalScopes()
                        ->where('store_id', $store->getKey())
                        ->whereRaw('lower(code) = ?', [Str::lower(trim((string) $value))])
                        ->when($this->discount instanceof Discount, fn ($query) => $query->where('id', '!=', $this->discount?->getKey()))
                        ->exists();

                    if ($exists) {
                        $fail(__('The discount code has already been taken.'));
                    }
                },
            ],
            'valueType' => ['required', Rule::in(['percent', 'fixed', 'free_shipping'])],
            'valueAmount' => $this->valueAmountRules(),
            'minimumPurchaseAmount' => ['nullable', 'numeric', 'min:0'],
            'usageLimit' => ['nullable', 'integer', 'min:1'],
            'startsAt' => ['required', 'date'],
            'endsAt' => ['nullable', 'date', 'after:startsAt'],
            'onePerCustomer' => ['boolean'],
            'isActive' => ['boolean'],
        ], [], [
            'valueAmount' => 'value amount',
        ]);

        $discount = $this->discount instanceof Discount
            ? tap($this->discount)->update($this->payload($store))
            : Discount::withoutGlobalScopes()->create($this->payload($store));

        $this->discount = $discount->refresh();
        $this->fillFromDiscount($this->discount);

        session()->flash('status', 'Discount saved');
        $this->dispatch('toast', type: 'success', message: __('Discount saved'));
    }

    public function generateCode(): void
    {
        do {
            $code = Str::upper(Str::random(8));
        } while (Discount::withoutGlobalScopes()
            ->where('store_id', $this->storeId())
            ->where('code', $code)
            ->exists());

        $this->code = $code;
    }

    public function addProduct(int $productId): void
    {
        abort_unless($this->productBelongsToStore($productId), 404);

        $this->specificProductIds = collect([...$this->specificProductIds, $productId])
            ->unique()
            ->values()
            ->all();
        $this->productSearch = '';
    }

    public function removeProduct(int $productId): void
    {
        $this->specificProductIds = array_values(array_filter(
            $this->specificProductIds,
            fn (int $selectedProductId): bool => $selectedProductId !== $productId,
        ));
    }

    public function addCollection(int $collectionId): void
    {
        abort_unless($this->collectionBelongsToStore($collectionId), 404);

        $this->specificCollectionIds = collect([...$this->specificCollectionIds, $collectionId])
            ->unique()
            ->values()
            ->all();
        $this->collectionSearch = '';
    }

    public function removeCollection(int $collectionId): void
    {
        $this->specificCollectionIds = array_values(array_filter(
            $this->specificCollectionIds,
            fn (int $selectedCollectionId): bool => $selectedCollectionId !== $collectionId,
        ));
    }

    public function productResults(): SupportCollection
    {
        if (trim($this->productSearch) === '') {
            return collect();
        }

        return Product::withoutGlobalScopes()
            ->where('store_id', $this->storeId())
            ->whereNotIn('id', $this->specificProductIds)
            ->where('title', 'like', '%'.$this->productSearch.'%')
            ->orderBy('title')
            ->limit(5)
            ->get();
    }

    public function collectionResults(): SupportCollection
    {
        if (trim($this->collectionSearch) === '') {
            return collect();
        }

        return ProductCollection::withoutGlobalScopes()
            ->where('store_id', $this->storeId())
            ->whereNotIn('id', $this->specificCollectionIds)
            ->where('title', 'like', '%'.$this->collectionSearch.'%')
            ->orderBy('title')
            ->limit(5)
            ->get();
    }

    public function selectedProducts(): SupportCollection
    {
        return Product::withoutGlobalScopes()
            ->where('store_id', $this->storeId())
            ->whereKey($this->specificProductIds)
            ->orderBy('title')
            ->get();
    }

    public function selectedCollections(): SupportCollection
    {
        return ProductCollection::withoutGlobalScopes()
            ->where('store_id', $this->storeId())
            ->whereKey($this->specificCollectionIds)
            ->orderBy('title')
            ->get();
    }

    public function render(): mixed
    {
        return view('livewire.admin.discounts.form', [
            'isEditing' => $this->discount instanceof Discount,
            'productResults' => $this->productResults(),
            'collectionResults' => $this->collectionResults(),
            'selectedProducts' => $this->selectedProducts(),
            'selectedCollections' => $this->selectedCollections(),
        ])->layout('layouts.app', [
            'title' => $this->discount ? __('Edit discount') : __('Create discount'),
        ]);
    }

    private function fillFromDiscount(Discount $discount): void
    {
        $this->type = $discount->type->value;
        $this->code = (string) $discount->code;
        $this->valueType = $discount->value_type->value;
        $this->valueAmount = $discount->value_type === DiscountValueType::Fixed
            ? number_format($discount->value_amount / 100, 2, '.', '')
            : (string) $discount->value_amount;
        $this->minimumPurchaseAmount = data_get($discount->rules_json, 'min_purchase_amount')
            ? number_format(((int) data_get($discount->rules_json, 'min_purchase_amount')) / 100, 2, '.', '')
            : '';
        $this->specificProductIds = collect(data_get($discount->rules_json, 'applicable_product_ids', []))
            ->map(fn (mixed $id): int => (int) $id)
            ->all();
        $this->specificCollectionIds = collect(data_get($discount->rules_json, 'applicable_collection_ids', []))
            ->map(fn (mixed $id): int => (int) $id)
            ->all();
        $this->usageLimit = $discount->usage_limit !== null ? (string) $discount->usage_limit : '';
        $this->onePerCustomer = (bool) data_get($discount->rules_json, 'one_per_customer', false);
        $this->startsAt = $discount->starts_at->format('Y-m-d\TH:i');
        $this->endsAt = $discount->ends_at?->format('Y-m-d\TH:i') ?? '';
        $this->isActive = $discount->status === DiscountStatus::Active;
    }

    /**
     * @return array<string, mixed>
     */
    private function payload(Store $store): array
    {
        return [


command: sed -n '1,260p' app/Models/Discount.php && sed -n '1,220p' database/factories/DiscountFactory.php

Discount.php

cmd: sed -n '1,260p' app/Models/Discount.php

DiscountFactory.php

aggregated_output:
<?php

namespace App\Models;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Discount extends Model
{
    /** @use HasFactory<\Database\Factories\DiscountFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'type',
        'code',
        'value_type',
        'value_amount',
        'starts_at',
        'ends_at',
        'usage_limit',
        'usage_count',
        'rules_json',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'code',
        'value_amount' => 0,
        'usage_count' => 0,
        'rules_json' => '{}',
        'status' => 'active',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => DiscountType::class,
            'value_type' => DiscountValueType::class,
            'value_amount' => 'integer',
            'starts_at' => 'datetime',
            'ends_at' => 'datetime',
            'usage_limit' => 'integer',
            'usage_count' => 'integer',
            'rules_json' => 'array',
            'status' => DiscountStatus::class,
        ];
    }
}
<?php

namespace Database\Factories;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Discount>
 */
class DiscountFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'type' => DiscountType::Code,
            'code' => fake()->unique()->bothify('SAVE##'),
            'value_type' => DiscountValueType::Percent,
            'value_amount' => 10,
            'starts_at' => now()->subDay(),
            'ends_at' => now()->addMonth(),
            'usage_limit' => null,
            'usage_count' => 0,
            'rules_json' => ['customer_eligibility' => 'all'],
            'status' => DiscountStatus::Active,
        ];
    }

    public function fixed(int $amount = 500): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::Fixed,
            'value_amount' => $amount,
        ]);
    }

    public function freeShipping(): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::FreeShipping,
            'value_amount' => 0,
        ]);
    }
}


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

namespace App\Models;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Discount extends Model
{
    /** @use HasFactory<\Database\Factories\DiscountFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'type',
        'code',
        'value_type',
        'value_amount',
        'starts_at',
        'ends_at',
        'usage_limit',
        'usage_count',
        'rules_json',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'code',
        'value_amount' => 0,
        'usage_count' => 0,
        'rules_json' => '{}',
        'status' => 'active',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => DiscountType::class,
            'value_type' => DiscountValueType::class,
            'value_amount' => 'integer',
            'starts_at' => 'datetime',
            'ends_at' => 'datetime',
            'usage_limit' => 'integer',
            'usage_count' => 'integer',
            'rules_json' => 'array',
            'status' => DiscountStatus::class,
        ];
    }
}
<?php

namespace Database\Factories;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Discount>
 */
class DiscountFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'type' => DiscountType::Code,
            'code' => fake()->unique()->bothify('SAVE##'),
            'value_type' => DiscountValueType::Percent,
            'value_amount' => 10,
            'starts_at' => now()->subDay(),
            'ends_at' => now()->addMonth(),
            'usage_limit' => null,
            'usage_count' => 0,
            'rules_json' => ['customer_eligibility' => 'all'],
            'status' => DiscountStatus::Active,
        ];
    }

    public function fixed(int $amount = 500): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::Fixed,
            'value_amount' => $amount,
        ]);
    }

    public function freeShipping(): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::FreeShipping,
            'value_amount' => 0,
        ]);
    }
}