type: message

role: assistant

text: There is no explicit general settings REST section in the spec, but the progress gap maps cleanly to the existing `store_settings` bag and the general settings UI fields. I’m adding `GET/PUT /api/admin/v1/stores/{store}/settings` under the existing read/write settings scopes, returning store defaults plus the settings JSON and domain list.


cmd: php artisan make:controller Api/Admin/V1/StoreSettingsController --no-interaction


cmd: php artisan make:resource Admin/V1/StoreSettingsResource --no-interaction


cmd: php artisan make:test --pest Api/AdminStoreSettingsApiTest --no-interaction


output:
Chunk ID: 900901
Wall time: 0.1844 seconds
Process exited with code 0
Original token count: 28
Output:

   INFO  Controller [app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php] created successfully.


output:
Chunk ID: d2bf0b
Wall time: 0.1721 seconds
Process exited with code 0
Original token count: 25
Output:

   INFO  Resource [app/Http/Resources/Admin/V1/StoreSettingsResource.php] created successfully.


output:
Chunk ID: 498d18
Wall time: 0.1697 seconds
Process exited with code 0
Original token count: 23
Output:

   INFO  Test [tests/Feature/Api/AdminStoreSettingsApiTest.php] created successfully.


command: php artisan make:controller Api/Admin/V1/StoreSettingsController --no-interaction

type: unknown

aggregated_output: INFO  Controller [app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php] created successfully.

status: completed


command: php artisan make:test --pest Api/AdminStoreSettingsApiTest --no-interaction

aggregated_output: INFO  Test [tests/Feature/Api/AdminStoreSettingsApiTest.php] created successfully.


command: php artisan make:resource Admin/V1/StoreSettingsResource --no-interaction

aggregated_output: INFO  Resource [app/Http/Resources/Admin/V1/StoreSettingsResource.php] created successfully.


status: completed

input:
*** Begin Patch
*** Update File: app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php
@@
 namespace App\Http\Controllers\Api\Admin\V1;

 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\StoreSettingsResource;
 use Illuminate\Http\Request;
+use App\Models\Store;
+use App\Models\StoreSettings;
+use Illuminate\Support\Arr;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;

 class StoreSettingsController extends Controller
 {
-    //
+    public function show(Request $request, Store $store): StoreSettingsResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->settings($store);
+
+        return StoreSettingsResource::make($this->loadStore($store));
+    }
+
+    public function update(Request $request, Store $store): StoreSettingsResource
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $request->validate([
+            'name' => ['sometimes', 'required', 'string', 'max:255'],
+            'default_currency' => ['sometimes', 'required', Rule::in($this->currencyOptions())],
+            'default_locale' => ['sometimes', 'required', Rule::in(array_keys($this->localeOptions()))],
+            'timezone' => ['sometimes', 'required', Rule::in(timezone_identifiers_list())],
+            'settings_json' => ['sometimes', 'array'],
+            'settings_json.announcement' => ['sometimes', 'array'],
+            'settings_json.announcement.enabled' => ['sometimes', 'boolean'],
+            'settings_json.announcement.text' => ['nullable', 'string', 'max:255'],
+            'settings_json.checkout' => ['sometimes', 'array'],
+            'settings_json.checkout.guest_checkout_enabled' => ['sometimes', 'boolean'],
+            'settings_json.checkout.customer_accounts_required' => ['sometimes', 'boolean'],
+            'settings_json.checkout.phone_number_required' => ['sometimes', 'boolean'],
+            'settings_json.checkout.billing_address_enabled' => ['sometimes', 'boolean'],
+            'settings_json.checkout.order_notes_enabled' => ['sometimes', 'boolean'],
+            'settings_json.checkout.terms_required' => ['sometimes', 'boolean'],
+            'settings_json.checkout.terms_url' => ['nullable', 'url', 'max:2048'],
+            'settings_json.checkout.payment_hold_hours' => ['sometimes', 'integer', 'min:1', 'max:168'],
+            'settings_json.checkout.abandoned_checkout_days' => ['sometimes', 'integer', 'min:1', 'max:60'],
+            'settings_json.bank_transfer_cancel_days' => ['sometimes', 'integer', 'min:1', 'max:60'],
+            'settings_json.notifications' => ['sometimes', 'array'],
+            'settings_json.notifications.sender_name' => ['nullable', 'string', 'max:255'],
+            'settings_json.notifications.sender_email' => ['nullable', 'email', 'max:255'],
+            'settings_json.notifications.reply_to_email' => ['nullable', 'email', 'max:255'],
+            'settings_json.notifications.order_confirmation_enabled' => ['sometimes', 'boolean'],
+            'settings_json.notifications.shipping_confirmation_enabled' => ['sometimes', 'boolean'],
+            'settings_json.notifications.refund_confirmation_enabled' => ['sometimes', 'boolean'],
+            'settings_json.notifications.admin_order_alerts_enabled' => ['sometimes', 'boolean'],
+            'settings_json.notifications.low_stock_alerts_enabled' => ['sometimes', 'boolean'],
+            'settings_json.notifications.low_stock_threshold' => ['sometimes', 'integer', 'min:0', 'max:1000'],
+        ]);
+
+        if (array_key_exists('settings_json', $validated)) {
+            $this->validateSettingsObject($validated['settings_json']);
+        }
+
+        $storeAttributes = Arr::only($validated, [
+            'name',
+            'default_currency',
+            'default_locale',
+            'timezone',
+        ]);
+
+        if ($storeAttributes !== []) {
+            $store->forceFill($storeAttributes)->save();
+        }
+
+        if (array_key_exists('settings_json', $validated)) {
+            $settings = $this->settings($store);
+            $settings->forceFill([
+                'settings_json' => array_replace_recursive($settings->settings_json ?? [], $validated['settings_json']),
+                'updated_at' => now(),
+            ])->save();
+        }
+
+        return StoreSettingsResource::make($this->loadStore($store->refresh()));
+    }
+
+    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 settings(Store $store): StoreSettings
+    {
+        return StoreSettings::query()->firstOrCreate(
+            ['store_id' => $store->getKey()],
+            ['settings_json' => []],
+        );
+    }
+
+    private function loadStore(Store $store): Store
+    {
+        return $store->load([
+            'settings',
+            'domains' => fn ($query) => $query->orderByDesc('is_primary')->orderBy('hostname'),
+        ]);
+    }
+
+    /**
+     * @param  array<string, mixed>  $settings
+     */
+    private function validateSettingsObject(array $settings): void
+    {
+        if (array_is_list($settings)) {
+            throw ValidationException::withMessages([
+                'settings_json' => __('The settings json field must be an object.'),
+            ]);
+        }
+
+        foreach (['announcement', 'checkout', 'notifications'] as $section) {
+            if (array_key_exists($section, $settings) && is_array($settings[$section]) && array_is_list($settings[$section])) {
+                throw ValidationException::withMessages([
+                    "settings_json.{$section}" => __('The :section settings must be an object.', ['section' => str_replace('_', ' ', $section)]),
+                ]);
+            }
+        }
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    private function localeOptions(): array
+    {
+        return [
+            'en' => 'English',
+            'de' => 'German',
+            'fr' => 'French',
+        ];
+    }
+
+    /**
+     * @return list<string>
+     */
+    private function currencyOptions(): array
+    {
+        return ['EUR', 'USD', 'GBP', 'CHF'];
+    }
 }
*** End Patch


stdout:
Success. Updated the following files:
M app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php

type: update

unified_diff:
@@ -5,3 +5,9 @@
 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\StoreSettingsResource;
 use Illuminate\Http\Request;
+use App\Models\Store;
+use App\Models\StoreSettings;
+use Illuminate\Support\Arr;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;

@@ -9,3 +15,136 @@
 {
-    //
+    public function show(Request $request, Store $store): StoreSettingsResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->settings($store);
+
+        return StoreSettingsResource::make($this->loadStore($store));
+    }
+
+    public function update(Request $request, Store $store): StoreSettingsResource
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $request->validate([
+            'name' => ['sometimes', 'required', 'string', 'max:255'],
+            'default_currency' => ['sometimes', 'required', Rule::in($this->currencyOptions())],
+            'default_locale' => ['sometimes', 'required', Rule::in(array_keys($this->localeOptions()))],
+            'timezone' => ['sometimes', 'required', Rule::in(timezone_identifiers_list())],
+            'settings_json' => ['sometimes', 'array'],
+            'settings_json.announcement' => ['sometimes', 'array'],
+            'settings_json.announcement.enabled' => ['sometimes', 'boolean'],
+            'settings_json.announcement.text' => ['nullable', 'string', 'max:255'],
+            'settings_json.checkout' => ['sometimes', 'array'],
+            'settings_json.checkout.guest_checkout_enabled' => ['sometimes', 'boolean'],
+            'settings_json.checkout.customer_accounts_required' => ['sometimes', 'boolean'],
+            'settings_json.checkout.phone_number_required' => ['sometimes', 'boolean'],
+            'settings_json.checkout.billing_address_enabled' => ['sometimes', 'boolean'],
+            'settings_json.checkout.order_notes_enabled' => ['sometimes', 'boolean'],
+            'settings_json.checkout.terms_required' => ['sometimes', 'boolean'],
+            'settings_json.checkout.terms_url' => ['nullable', 'url', 'max:2048'],
+            'settings_json.checkout.payment_hold_hours' => ['sometimes', 'integer', 'min:1', 'max:168'],
+            'settings_json.checkout.abandoned_checkout_days' => ['sometimes', 'integer', 'min:1', 'max:60'],
+            'settings_json.bank_transfer_cancel_days' => ['sometimes', 'integer', 'min:1', 'max:60'],
+            'settings_json.notifications' => ['sometimes', 'array'],
+            'settings_json.notifications.sender_name' => ['nullable', 'string', 'max:255'],
+            'settings_json.notifications.sender_email' => ['nullable', 'email', 'max:255'],
+            'settings_json.notifications.reply_to_email' => ['nullable', 'email', 'max:255'],
+            'settings_json.notifications.order_confirmation_enabled' => ['sometimes', 'boolean'],
+            'settings_json.notifications.shipping_confirmation_enabled' => ['sometimes', 'boolean'],
+            'settings_json.notifications.refund_confirmation_enabled' => ['sometimes', 'boolean'],
+            'settings_json.notifications.admin_order_alerts_enabled' => ['sometimes', 'boolean'],
+            'settings_json.notifications.low_stock_alerts_enabled' => ['sometimes', 'boolean'],
+            'settings_json.notifications.low_stock_threshold' => ['sometimes', 'integer', 'min:0', 'max:1000'],
+        ]);
+
+        if (array_key_exists('settings_json', $validated)) {
+            $this->validateSettingsObject($validated['settings_json']);
+        }
+
+        $storeAttributes = Arr::only($validated, [
+            'name',
+            'default_currency',
+            'default_locale',
+            'timezone',
+        ]);
+
+        if ($storeAttributes !== []) {
+            $store->forceFill($storeAttributes)->save();
+        }
+
+        if (array_key_exists('settings_json', $validated)) {
+            $settings = $this->settings($store);
+            $settings->forceFill([
+                'settings_json' => array_replace_recursive($settings->settings_json ?? [], $validated['settings_json']),
+                'updated_at' => now(),
+            ])->save();
+        }
+
+        return StoreSettingsResource::make($this->loadStore($store->refresh()));
+    }
+
+    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 settings(Store $store): StoreSettings
+    {
+        return StoreSettings::query()->firstOrCreate(
+            ['store_id' => $store->getKey()],
+            ['settings_json' => []],
+        );
+    }
+
+    private function loadStore(Store $store): Store
+    {
+        return $store->load([
+            'settings',
+            'domains' => fn ($query) => $query->orderByDesc('is_primary')->orderBy('hostname'),
+        ]);
+    }
+
+    /**
+     * @param  array<string, mixed>  $settings
+     */
+    private function validateSettingsObject(array $settings): void
+    {
+        if (array_is_list($settings)) {
+            throw ValidationException::withMessages([
+                'settings_json' => __('The settings json field must be an object.'),
+            ]);
+        }
+
+        foreach (['announcement', 'checkout', 'notifications'] as $section) {
+            if (array_key_exists($section, $settings) && is_array($settings[$section]) && array_is_list($settings[$section])) {
+                throw ValidationException::withMessages([
+                    "settings_json.{$section}" => __('The :section settings must be an object.', ['section' => str_replace('_', ' ', $section)]),
+                ]);
+            }
+        }
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    private function localeOptions(): array
+    {
+        return [
+            'en' => 'English',
+            'de' => 'German',
+            'fr' => 'French',
+        ];
+    }
+
+    /**
+     * @return list<string>
+     */
+    private function currencyOptions(): array
+    {
+        return ['EUR', 'USD', 'GBP', 'CHF'];
+    }
 }


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


status: completed

input:
*** Begin Patch
*** Update File: app/Http/Resources/Admin/V1/StoreSettingsResource.php
@@
 namespace App\Http\Resources\Admin\V1;

+use App\Models\StoreDomain;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;

 class StoreSettingsResource extends JsonResource
@@
     public function toArray(Request $request): array
     {
-        return parent::toArray($request);
+        return [
+            'id' => $this->id,
+            'name' => $this->name,
+            'handle' => $this->handle,
+            'status' => $this->status?->value,
+            'default_currency' => $this->default_currency,
+            'default_locale' => $this->default_locale,
+            'timezone' => $this->timezone,
+            'settings_json' => $this->settings?->settings_json ?? [],
+            'settings_updated_at' => $this->settings?->updated_at?->toIso8601String(),
+            'updated_at' => $this->updated_at?->toIso8601String(),
+            'domains' => $this->whenLoaded('domains', fn (): array => $this->domains
+                ->map(fn (StoreDomain $domain): array => [
+                    'id' => $domain->id,
+                    'hostname' => $domain->hostname,
+                    'type' => $domain->type?->value,
+                    'is_primary' => $domain->is_primary,
+                    'tls_mode' => $domain->tls_mode,
+                    'created_at' => $domain->created_at?->toIso8601String(),
+                ])
+                ->values()
+                ->all()),
+        ];
     }
 }
*** End Patch


stdout:
Success. Updated the following files:
M app/Http/Resources/Admin/V1/StoreSettingsResource.php

unified_diff:
@@ -4,2 +4,3 @@

+use App\Models\StoreDomain;
 use Illuminate\Http\Request;
@@ -16,3 +17,25 @@
     {
-        return parent::toArray($request);
+        return [
+            'id' => $this->id,
+            'name' => $this->name,
+            'handle' => $this->handle,
+            'status' => $this->status?->value,
+            'default_currency' => $this->default_currency,
+            'default_locale' => $this->default_locale,
+            'timezone' => $this->timezone,
+            'settings_json' => $this->settings?->settings_json ?? [],
+            'settings_updated_at' => $this->settings?->updated_at?->toIso8601String(),
+            'updated_at' => $this->updated_at?->toIso8601String(),
+            'domains' => $this->whenLoaded('domains', fn (): array => $this->domains
+                ->map(fn (StoreDomain $domain): array => [
+                    'id' => $domain->id,
+                    'hostname' => $domain->hostname,
+                    'type' => $domain->type?->value,
+                    'is_primary' => $domain->is_primary,
+                    'tls_mode' => $domain->tls_mode,
+                    'created_at' => $domain->created_at?->toIso8601String(),
+                ])
+                ->values()
+                ->all()),
+        ];
     }


output: {"output":"Success. Updated the following files:\nM app/Http/Resources/Admin/V1/StoreSettingsResource.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: routes/api.php
@@
 use App\Http\Controllers\Api\Admin\V1\ShippingZoneController as AdminShippingZoneController;
+use App\Http\Controllers\Api\Admin\V1\StoreSettingsController as AdminStoreSettingsController;
 use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
@@
         Route::middleware('admin.api:read-settings')->group(function (): void {
             Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
+            Route::get('settings', [AdminStoreSettingsController::class, 'show'])->name('settings.show');
             Route::get('shipping/zones', [AdminShippingZoneController::class, 'index'])->name('shipping.zones.index');
             Route::get('tax/settings', [AdminTaxSettingsController::class, 'show'])->name('tax.settings.show');
         });

         Route::middleware('admin.api:write-settings')->group(function (): void {
             Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
+            Route::put('settings', [AdminStoreSettingsController::class, 'update'])->name('settings.update');
             Route::post('shipping/zones', [AdminShippingZoneController::class, 'store'])->name('shipping.zones.store');
*** End Patch


stdout:
Success. Updated the following files:
M routes/api.php

unified_diff:
@@ -14,2 +14,3 @@
 use App\Http\Controllers\Api\Admin\V1\ShippingZoneController as AdminShippingZoneController;
+use App\Http\Controllers\Api\Admin\V1\StoreSettingsController as AdminStoreSettingsController;
 use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
@@ -106,2 +107,3 @@
             Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
+            Route::get('settings', [AdminStoreSettingsController::class, 'show'])->name('settings.show');
             Route::get('shipping/zones', [AdminShippingZoneController::class, 'index'])->name('shipping.zones.index');
@@ -112,2 +114,3 @@
             Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
+            Route::put('settings', [AdminStoreSettingsController::class, 'update'])->name('settings.update');
             Route::post('shipping/zones', [AdminShippingZoneController::class, 'store'])->name('shipping.zones.store');


output: {"output":"Success. Updated the following files:\nM routes/api.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Api/AdminStoreSettingsApiTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
+use App\Models\Store;
+use App\Models\StoreSettings;
+use App\Models\User;
+use App\Services\WebhookService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;

-    $response->assertStatus(200);
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminStoreSettingsApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminStoreSettingsApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminStoreSettingsApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Store settings integration', $abilities);
+}
+
+test('admin store settings api shows and updates general settings', function (): void {
+    $store = adminStoreSettingsApiStore();
+    $user = adminStoreSettingsApiUser();
+
+    $this->actingAs($user)
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings")
+        ->assertOk()
+        ->assertJsonPath('data.name', 'Acme Fashion')
+        ->assertJsonPath('data.default_currency', 'EUR')
+        ->assertJsonPath('data.settings_json.announcement.enabled', true)
+        ->assertJsonPath('data.domains.0.is_primary', true);
+
+    $this->actingAs($user)
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'name' => 'Acme API Store',
+            'default_currency' => 'USD',
+            'default_locale' => 'de',
+            'timezone' => 'Europe/Paris',
+            'settings_json' => [
+                'announcement' => [
+                    'enabled' => false,
+                    'text' => 'API announcement',
+                ],
+                'checkout' => [
+                    'terms_required' => true,
+                    'terms_url' => 'https://example.test/terms',
+                ],
+                'bank_transfer_cancel_days' => 10,
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.name', 'Acme API Store')
+        ->assertJsonPath('data.default_currency', 'USD')
+        ->assertJsonPath('data.default_locale', 'de')
+        ->assertJsonPath('data.timezone', 'Europe/Paris')
+        ->assertJsonPath('data.settings_json.announcement.enabled', false)
+        ->assertJsonPath('data.settings_json.checkout.terms_required', true)
+        ->assertJsonPath('data.settings_json.notifications.sender_email', 'no-reply@shop.test');
+
+    $settings = StoreSettings::query()->whereKey($store->getKey())->firstOrFail();
+
+    expect($store->refresh()->name)->toBe('Acme API Store')
+        ->and($store->default_currency)->toBe('USD')
+        ->and($settings->settings_json['announcement']['text'])->toBe('API announcement')
+        ->and($settings->settings_json['checkout']['terms_url'])->toBe('https://example.test/terms')
+        ->and($settings->settings_json['notifications']['sender_email'])->toBe('no-reply@shop.test');
+});
+
+test('admin store settings api enforces token abilities and store scope', function (): void {
+    $store = adminStoreSettingsApiStore();
+    $otherStore = Store::factory()->create();
+    $readToken = adminStoreSettingsApiToken($store, ['read-settings']);
+    $writeToken = adminStoreSettingsApiToken($store, ['write-settings']);
+    $otherStoreToken = adminStoreSettingsApiToken($otherStore, ['read-settings']);
+
+    $this->withToken($readToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings")
+        ->assertOk();
+
+    $this->withToken($readToken['plain_text'])
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'name' => 'Read Only API Store',
+        ])
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings")
+        ->assertForbidden();
+
+    $this->withToken($writeToken['plain_text'])
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'settings_json' => [
+                'notifications' => [
+                    'low_stock_threshold' => 12,
+                ],
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.settings_json.notifications.low_stock_threshold', 12);
+});
+
+test('admin store settings api validates defaults and settings shape', function (): void {
+    $store = adminStoreSettingsApiStore();
+
+    $this->actingAs(adminStoreSettingsApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'default_currency' => 'BTC',
+            'default_locale' => 'es',
+            'timezone' => 'Mars/Olympus',
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['default_currency', 'default_locale', 'timezone']);
+
+    $this->actingAs(adminStoreSettingsApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'settings_json' => ['invalid-list-value'],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['settings_json']);
+
+    $this->actingAs(adminStoreSettingsApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'settings_json' => [
+                'checkout' => [
+                    'terms_url' => 'not-a-url',
+                    'payment_hold_hours' => 0,
+                ],
+            ],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['settings_json.checkout.terms_url', 'settings_json.checkout.payment_hold_hours']);
 });
*** End Patch


stdout:
Success. Updated the following files:
M tests/Feature/Api/AdminStoreSettingsApiTest.php

unified_diff:
@@ -2,6 +2,146 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Models\Store;
+use App\Models\StoreSettings;
+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 adminStoreSettingsApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminStoreSettingsApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminStoreSettingsApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Store settings integration', $abilities);
+}
+
+test('admin store settings api shows and updates general settings', function (): void {
+    $store = adminStoreSettingsApiStore();
+    $user = adminStoreSettingsApiUser();
+
+    $this->actingAs($user)
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings")
+        ->assertOk()
+        ->assertJsonPath('data.name', 'Acme Fashion')
+        ->assertJsonPath('data.default_currency', 'EUR')
+        ->assertJsonPath('data.settings_json.announcement.enabled', true)
+        ->assertJsonPath('data.domains.0.is_primary', true);
+
+    $this->actingAs($user)
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'name' => 'Acme API Store',
+            'default_currency' => 'USD',
+            'default_locale' => 'de',
+            'timezone' => 'Europe/Paris',
+            'settings_json' => [
+                'announcement' => [
+                    'enabled' => false,
+                    'text' => 'API announcement',
+                ],
+                'checkout' => [
+                    'terms_required' => true,
+                    'terms_url' => 'https://example.test/terms',
+                ],
+                'bank_transfer_cancel_days' => 10,
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.name', 'Acme API Store')
+        ->assertJsonPath('data.default_currency', 'USD')
+        ->assertJsonPath('data.default_locale', 'de')
+        ->assertJsonPath('data.timezone', 'Europe/Paris')
+        ->assertJsonPath('data.settings_json.announcement.enabled', false)
+        ->assertJsonPath('data.settings_json.checkout.terms_required', true)
+        ->assertJsonPath('data.settings_json.notifications.sender_email', 'no-reply@shop.test');
+
+    $settings = StoreSettings::query()->whereKey($store->getKey())->firstOrFail();
+
+    expect($store->refresh()->name)->toBe('Acme API Store')
+        ->and($store->default_currency)->toBe('USD')
+        ->and($settings->settings_json['announcement']['text'])->toBe('API announcement')
+        ->and($settings->settings_json['checkout']['terms_url'])->toBe('https://example.test/terms')
+        ->and($settings->settings_json['notifications']['sender_email'])->toBe('no-reply@shop.test');
+});

-    $response->assertStatus(200);
+test('admin store settings api enforces token abilities and store scope', function (): void {
+    $store = adminStoreSettingsApiStore();
+    $otherStore = Store::factory()->create();
+    $readToken = adminStoreSettingsApiToken($store, ['read-settings']);
+    $writeToken = adminStoreSettingsApiToken($store, ['write-settings']);
+    $otherStoreToken = adminStoreSettingsApiToken($otherStore, ['read-settings']);
+
+    $this->withToken($readToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings")
+        ->assertOk();
+
+    $this->withToken($readToken['plain_text'])
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'name' => 'Read Only API Store',
+        ])
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings")
+        ->assertForbidden();
+
+    $this->withToken($writeToken['plain_text'])
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'settings_json' => [
+                'notifications' => [
+                    'low_stock_threshold' => 12,
+                ],
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.settings_json.notifications.low_stock_threshold', 12);
+});
+
+test('admin store settings api validates defaults and settings shape', function (): void {
+    $store = adminStoreSettingsApiStore();
+
+    $this->actingAs(adminStoreSettingsApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'default_currency' => 'BTC',
+            'default_locale' => 'es',
+            'timezone' => 'Mars/Olympus',
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['default_currency', 'default_locale', 'timezone']);
+
+    $this->actingAs(adminStoreSettingsApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'settings_json' => ['invalid-list-value'],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['settings_json']);
+
+    $this->actingAs(adminStoreSettingsApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
+            'settings_json' => [
+                'checkout' => [
+                    'terms_url' => 'not-a-url',
+                    'payment_hold_hours' => 0,
+                ],
+            ],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['settings_json.checkout.terms_url', 'settings_json.checkout.payment_hold_hours']);
 });


output: {"output":"Success. Updated the following files:\nM tests/Feature/Api/AdminStoreSettingsApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: The settings API controller, resource, routes, and tests are drafted. I’m running the focused settings API test now to catch validation or resource-shape issues before formatting.

phase: commentary


text: The settings API controller, resource, routes, and tests are drafted. I’m running the focused settings API test now to catch validation or resource-shape issues before formatting.


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


output:
Chunk ID: 935299
Wall time: 1.5866 seconds
Process exited with code 1
Original token count: 264
Output:

  ..⨯
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Api\AdminStoreSettingsApiTest > admin store setting…
  Expected response status code [422] but received 200.
Failed asserting that 200 is identical to 422.

  at tests/Feature/Api/AdminStoreSettingsApiTest.php:133
    129▕     $this->actingAs(adminStoreSettingsApiUser())
    130▕         ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
    131▕             'settings_json' => ['invalid-list-value'],
    132▕         ])
  ➜ 133▕         ->assertUnprocessable()
    134▕         ->assertJsonValidationErrors(['settings_json']);
    135▕
    136▕     $this->actingAs(adminStoreSettingsApiUser())
    137▕         ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [

  Tests:    1 failed, 2 passed (29 assertions)
  Duration: 1.31s


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

aggregated_output:
  ..⨯
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Api\AdminStoreSettingsApiTest > admin store setting…
  Expected response status code [422] but received 200.
Failed asserting that 200 is identical to 422.

  at tests/Feature/Api/AdminStoreSettingsApiTest.php:133
    129▕     $this->actingAs(adminStoreSettingsApiUser())
    130▕         ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
    131▕             'settings_json' => ['invalid-list-value'],
    132▕         ])
  ➜ 133▕         ->assertUnprocessable()
    134▕         ->assertJsonValidationErrors(['settings_json']);
    135▕
    136▕     $this->actingAs(adminStoreSettingsApiUser())
    137▕         ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [

  Tests:    1 failed, 2 passed (29 assertions)
  Duration: 1.31s

status: failed


status: completed

input:
*** Begin Patch
*** Update File: app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php
@@
-        if (array_key_exists('settings_json', $validated)) {
-            $this->validateSettingsObject($validated['settings_json']);
+        $settingsPayload = $request->input('settings_json');
+
+        if ($request->exists('settings_json') && is_array($settingsPayload)) {
+            $this->validateSettingsObject($settingsPayload);
@@
-        if (array_key_exists('settings_json', $validated)) {
+        if ($request->exists('settings_json') && is_array($settingsPayload)) {
             $settings = $this->settings($store);
             $settings->forceFill([
-                'settings_json' => array_replace_recursive($settings->settings_json ?? [], $validated['settings_json']),
+                'settings_json' => array_replace_recursive($settings->settings_json ?? [], $settingsPayload),
                 'updated_at' => now(),
             ])->save();
         }
*** End Patch


unified_diff:
@@ -59,4 +59,6 @@

-        if (array_key_exists('settings_json', $validated)) {
-            $this->validateSettingsObject($validated['settings_json']);
+        $settingsPayload = $request->input('settings_json');
+
+        if ($request->exists('settings_json') && is_array($settingsPayload)) {
+            $this->validateSettingsObject($settingsPayload);
         }
@@ -74,6 +76,6 @@

-        if (array_key_exists('settings_json', $validated)) {
+        if ($request->exists('settings_json') && is_array($settingsPayload)) {
             $settings = $this->settings($store);
             $settings->forceFill([
-                'settings_json' => array_replace_recursive($settings->settings_json ?? [], $validated['settings_json']),
+                'settings_json' => array_replace_recursive($settings->settings_json ?? [], $settingsPayload),
                 'updated_at' => now(),


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


status: completed

input:
*** Begin Patch
*** Update File: app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php
@@
-        if (array_is_list($settings)) {
+        if ($settings !== [] && array_is_list($settings)) {
@@
-            if (array_key_exists($section, $settings) && is_array($settings[$section]) && array_is_list($settings[$section])) {
+            if (array_key_exists($section, $settings) && is_array($settings[$section]) && $settings[$section] !== [] && array_is_list($settings[$section])) {
*** End Patch


unified_diff:
@@ -117,3 +117,3 @@
     {
-        if (array_is_list($settings)) {
+        if ($settings !== [] && array_is_list($settings)) {
             throw ValidationException::withMessages([
@@ -124,3 +124,3 @@
         foreach (['announcement', 'checkout', 'notifications'] as $section) {
-            if (array_key_exists($section, $settings) && is_array($settings[$section]) && array_is_list($settings[$section])) {
+            if (array_key_exists($section, $settings) && is_array($settings[$section]) && $settings[$section] !== [] && array_is_list($settings[$section])) {
                 throw ValidationException::withMessages([


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


status: completed

input:
*** Begin Patch
*** Update File: app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php
@@
-        if (array_is_list($validated['settings_json'])) {
+        if ($validated['settings_json'] !== [] && array_is_list($validated['settings_json'])) {
*** End Patch


stdout:
Success. Updated the following files:
M app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php

unified_diff:
@@ -24,3 +24,3 @@

-        if (array_is_list($validated['settings_json'])) {
+        if ($validated['settings_json'] !== [] && array_is_list($validated['settings_json'])) {
             throw ValidationException::withMessages([


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


output:
Chunk ID: 2b2e4d
Wall time: 1.6316 seconds
Process exited with code 0
Original token count: 16
Output:

  ...

  Tests:    3 passed (35 assertions)
  Duration: 1.40s


aggregated_output:
  ...

  Tests:    3 passed (35 assertions)
  Duration: 1.40s


message: The focused settings API file is now green after switching the settings merge to use the original JSON payload rather than Laravel’s filtered validated subset. I’m formatting the PHP changes and then I’ll run the affected theme/settings and full API tests.


text: The focused settings API file is now green after switching the settings merge to use the original JSON payload rather than Laravel’s filtered validated subset. I’m formatting the PHP changes and then I’ll run the affected theme/settings and full API tests.


output:
Chunk ID: ee1f17
Wall time: 0.3911 seconds
Process exited with code 0
Original token count: 49
Output:
{"result":"fixed","files":[{"path":"app\/Http\/Controllers\/Api\/Admin\/V1\/StoreSettingsController.php","fixers":["unary_operator_spaces","not_operator_with_successor_space","ordered_imports"]}]}


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

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

result: fixed


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


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


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


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

StoreSettingsResource.php

type: read

aggregated_output:
<?php

namespace App\Http\Resources\Admin\V1;

use App\Models\StoreDomain;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class StoreSettingsResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'handle' => $this->handle,
            'status' => $this->status?->value,
            'default_currency' => $this->default_currency,
            'default_locale' => $this->default_locale,
            'timezone' => $this->timezone,
            'settings_json' => $this->settings?->settings_json ?? [],
            'settings_updated_at' => $this->settings?->updated_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
            'domains' => $this->whenLoaded('domains', fn (): array => $this->domains
                ->map(fn (StoreDomain $domain): array => [
                    'id' => $domain->id,
                    'hostname' => $domain->hostname,
                    'type' => $domain->type?->value,
                    'is_primary' => $domain->is_primary,
                    'tls_mode' => $domain->tls_mode,
                    'created_at' => $domain->created_at?->toIso8601String(),
                ])
                ->values()
                ->all()),
        ];
    }
}


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

aggregated_output:
<?php

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

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\StoreSettingsResource;
use App\Models\Store;
use App\Models\StoreSettings;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;

class StoreSettingsController extends Controller
{
    public function show(Request $request, Store $store): StoreSettingsResource
    {
        $this->authorizeStore($request, $store);
        $this->settings($store);

        return StoreSettingsResource::make($this->loadStore($store));
    }

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

        $validated = $request->validate([
            'name' => ['sometimes', 'required', 'string', 'max:255'],
            'default_currency' => ['sometimes', 'required', Rule::in($this->currencyOptions())],
            'default_locale' => ['sometimes', 'required', Rule::in(array_keys($this->localeOptions()))],
            'timezone' => ['sometimes', 'required', Rule::in(timezone_identifiers_list())],
            'settings_json' => ['sometimes', 'array'],
            'settings_json.announcement' => ['sometimes', 'array'],
            'settings_json.announcement.enabled' => ['sometimes', 'boolean'],
            'settings_json.announcement.text' => ['nullable', 'string', 'max:255'],
            'settings_json.checkout' => ['sometimes', 'array'],
            'settings_json.checkout.guest_checkout_enabled' => ['sometimes', 'boolean'],
            'settings_json.checkout.customer_accounts_required' => ['sometimes', 'boolean'],
            'settings_json.checkout.phone_number_required' => ['sometimes', 'boolean'],
            'settings_json.checkout.billing_address_enabled' => ['sometimes', 'boolean'],
            'settings_json.checkout.order_notes_enabled' => ['sometimes', 'boolean'],
            'settings_json.checkout.terms_required' => ['sometimes', 'boolean'],
            'settings_json.checkout.terms_url' => ['nullable', 'url', 'max:2048'],
            'settings_json.checkout.payment_hold_hours' => ['sometimes', 'integer', 'min:1', 'max:168'],
            'settings_json.checkout.abandoned_checkout_days' => ['sometimes', 'integer', 'min:1', 'max:60'],
            'settings_json.bank_transfer_cancel_days' => ['sometimes', 'integer', 'min:1', 'max:60'],
            'settings_json.notifications' => ['sometimes', 'array'],
            'settings_json.notifications.sender_name' => ['nullable', 'string', 'max:255'],
            'settings_json.notifications.sender_email' => ['nullable', 'email', 'max:255'],
            'settings_json.notifications.reply_to_email' => ['nullable', 'email', 'max:255'],
            'settings_json.notifications.order_confirmation_enabled' => ['sometimes', 'boolean'],
            'settings_json.notifications.shipping_confirmation_enabled' => ['sometimes', 'boolean'],
            'settings_json.notifications.refund_confirmation_enabled' => ['sometimes', 'boolean'],
            'settings_json.notifications.admin_order_alerts_enabled' => ['sometimes', 'boolean'],
            'settings_json.notifications.low_stock_alerts_enabled' => ['sometimes', 'boolean'],
            'settings_json.notifications.low_stock_threshold' => ['sometimes', 'integer', 'min:0', 'max:1000'],
        ]);

        $settingsPayload = $request->input('settings_json');

        if ($request->exists('settings_json') && is_array($settingsPayload)) {
            $this->validateSettingsObject($settingsPayload);
        }

        $storeAttributes = Arr::only($validated, [
            'name',
            'default_currency',
            'default_locale',
            'timezone',
        ]);

        if ($storeAttributes !== []) {
            $store->forceFill($storeAttributes)->save();
        }

        if ($request->exists('settings_json') && is_array($settingsPayload)) {
            $settings = $this->settings($store);
            $settings->forceFill([
                'settings_json' => array_replace_recursive($settings->settings_json ?? [], $settingsPayload),
                'updated_at' => now(),
            ])->save();
        }

        return StoreSettingsResource::make($this->loadStore($store->refresh()));
    }

    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 settings(Store $store): StoreSettings
    {
        return StoreSettings::query()->firstOrCreate(
            ['store_id' => $store->getKey()],
            ['settings_json' => []],
        );
    }

    private function loadStore(Store $store): Store
    {
        return $store->load([
            'settings',
            'domains' => fn ($query) => $query->orderByDesc('is_primary')->orderBy('hostname'),
        ]);
    }

    /**
     * @param  array<string, mixed>  $settings
     */
    private function validateSettingsObject(array $settings): void
    {
        if ($settings !== [] && array_is_list($settings)) {
            throw ValidationException::withMessages([
                'settings_json' => __('The settings json field must be an object.'),
            ]);
        }

        foreach (['announcement', 'checkout', 'notifications'] as $section) {
            if (array_key_exists($section, $settings) && is_array($settings[$section]) && $settings[$section] !== [] && array_is_list($settings[$section])) {
                throw ValidationException::withMessages([
                    "settings_json.{$section}" => __('The :section settings must be an object.', ['section' => str_replace('_', ' ', $section)]),
                ]);
            }
        }
    }

    /**
     * @return array<string, string>
     */
    private function localeOptions(): array
    {
        return [
            'en' => 'English',
            'de' => 'German',
            'fr' => 'French',
        ];
    }

    /**
     * @return list<string>
     */
    private function currencyOptions(): array
    {
        return ['EUR', 'USD', 'GBP', 'CHF'];
    }
}


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

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

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\StoreSettingsResource;
use App\Models\Store;
use App\Models\StoreSettings;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;

class StoreSettingsController extends Controller
{
    public function show(Request $request, Store $store): StoreSettingsResource
    {
        $this->authorizeStore($request, $store);
        $this->settings($store);

        return StoreSettingsResource::make($this->loadStore($store));
    }

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

        $validated = $request->validate([
            'name' => ['sometimes', 'required', 'string', 'max:255'],
            'default_currency' => ['sometimes', 'required', Rule::in($this->currencyOptions())],
            'default_locale' => ['sometimes', 'required', Rule::in(array_keys($this->localeOptions()))],
            'timezone' => ['sometimes', 'required', Rule::in(timezone_identifiers_list())],
            'settings_json' => ['sometimes', 'array'],
            'settings_json.announcement' => ['sometimes', 'array'],
            'settings_json.announcement.enabled' => ['sometimes', 'boolean'],
            'settings_json.announcement.text' => ['nullable', 'string', 'max:255'],
            'settings_json.checkout' => ['sometimes', 'array'],
            'settings_json.checkout.guest_checkout_enabled' => ['sometimes', 'boolean'],
            'settings_json.checkout.customer_accounts_required' => ['sometimes', 'boolean'],
            'settings_json.checkout.phone_number_required' => ['sometimes', 'boolean'],
            'settings_json.checkout.billing_address_enabled' => ['sometimes', 'boolean'],
            'settings_json.checkout.order_notes_enabled' => ['sometimes', 'boolean'],
            'settings_json.checkout.terms_required' => ['sometimes', 'boolean'],
            'settings_json.checkout.terms_url' => ['nullable', 'url', 'max:2048'],
            'settings_json.checkout.payment_hold_hours' => ['sometimes', 'integer', 'min:1', 'max:168'],
            'settings_json.checkout.abandoned_checkout_days' => ['sometimes', 'integer', 'min:1', 'max:60'],
            'settings_json.bank_transfer_cancel_days' => ['sometimes', 'integer', 'min:1', 'max:60'],
            'settings_json.notifications' => ['sometimes', 'array'],
            'settings_json.notifications.sender_name' => ['nullable', 'string', 'max:255'],
            'settings_json.notifications.sender_email' => ['nullable', 'email', 'max:255'],
            'settings_json.notifications.reply_to_email' => ['nullable', 'email', 'max:255'],
            'settings_json.notifications.order_confirmation_enabled' => ['sometimes', 'boolean'],
            'settings_json.notifications.shipping_confirmation_enabled' => ['sometimes', 'boolean'],
            'settings_json.notifications.refund_confirmation_enabled' => ['sometimes', 'boolean'],
            'settings_json.notifications.admin_order_alerts_enabled' => ['sometimes', 'boolean'],
            'settings_json.notifications.low_stock_alerts_enabled' => ['sometimes', 'boolean'],
            'settings_json.notifications.low_stock_threshold' => ['sometimes', 'integer', 'min:0', 'max:1000'],
        ]);

        $settingsPayload = $request->input('settings_json');

        if ($request->exists('settings_json') && is_array($settingsPayload)) {
            $this->validateSettingsObject($settingsPayload);
        }

        $storeAttributes = Arr::only($validated, [
            'name',
            'default_currency',
            'default_locale',
            'timezone',
        ]);

        if ($storeAttributes !== []) {
            $store->forceFill($storeAttributes)->save();
        }

        if ($request->exists('settings_json') && is_array($settingsPayload)) {
            $settings = $this->settings($store);
            $settings->forceFill([
                'settings_json' => array_replace_recursive($settings->settings_json ?? [], $settingsPayload),
                'updated_at' => now(),
            ])->save();
        }

        return StoreSettingsResource::make($this->loadStore($store->refresh()));
    }

    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 settings(Store $store): StoreSettings
    {
        return StoreSettings::query()->firstOrCreate(
            ['store_id' => $store->getKey()],
            ['settings_json' => []],
        );
    }

    private function loadStore(Store $store): Store
    {
        return $store->load([
            'settings',
            'domains' => fn ($query) => $query->orderByDesc('is_primary')->orderBy('hostname'),
        ]);
    }

    /**
     * @param  array<string, mixed>  $settings
     */
    private function validateSettingsObject(array $settings): void
    {
        if ($settings !== [] && array_is_list($settings)) {
            throw ValidationException::withMessages([
                'settings_json' => __('The settings json field must be an object.'),
            ]);
        }

        foreach (['announcement', 'checkout', 'notifications'] as $section) {
            if (array_key_exists($section, $settings) && is_array($settings[$section]) && $settings[$section] !== [] && array_is_list($settings[$section])) {
                throw ValidationException::withMessages([
                    "settings_json.{$section}" => __('The :section settings must be an object.', ['section' => str_replace('_', ' ', $section)]),
                ]);
            }
        }
    }

    /**
     * @return array<string, string>
     */
    private function localeOptions(): array
    {
        return [
            'en' => 'English',
            'de' => 'German',
            'fr' => 'French',
        ];
    }

    /**
     * @return list<string>
     */
    private function currencyOptions(): array
    {
        return ['EUR', 'USD', 'GBP', 'CHF'];
    }
}


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

namespace App\Http\Resources\Admin\V1;

use App\Models\StoreDomain;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class StoreSettingsResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'handle' => $this->handle,
            'status' => $this->status?->value,
            'default_currency' => $this->default_currency,
            'default_locale' => $this->default_locale,
            'timezone' => $this->timezone,
            'settings_json' => $this->settings?->settings_json ?? [],
            'settings_updated_at' => $this->settings?->updated_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
            'domains' => $this->whenLoaded('domains', fn (): array => $this->domains
                ->map(fn (StoreDomain $domain): array => [
                    'id' => $domain->id,
                    'hostname' => $domain->hostname,
                    'type' => $domain->type?->value,
                    'is_primary' => $domain->is_primary,
                    'tls_mode' => $domain->tls_mode,
                    'created_at' => $domain->created_at?->toIso8601String(),
                ])
                ->values()
                ->all()),
        ];
    }
}


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

aggregated_output:
<?php

use App\Models\Store;
use App\Models\StoreSettings;
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 adminStoreSettingsApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

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

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

test('admin store settings api shows and updates general settings', function (): void {
    $store = adminStoreSettingsApiStore();
    $user = adminStoreSettingsApiUser();

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings")
        ->assertOk()
        ->assertJsonPath('data.name', 'Acme Fashion')
        ->assertJsonPath('data.default_currency', 'EUR')
        ->assertJsonPath('data.settings_json.announcement.enabled', true)
        ->assertJsonPath('data.domains.0.is_primary', true);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'name' => 'Acme API Store',
            'default_currency' => 'USD',
            'default_locale' => 'de',
            'timezone' => 'Europe/Paris',
            'settings_json' => [
                'announcement' => [
                    'enabled' => false,
                    'text' => 'API announcement',
                ],
                'checkout' => [
                    'terms_required' => true,
                    'terms_url' => 'https://example.test/terms',
                ],
                'bank_transfer_cancel_days' => 10,
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.name', 'Acme API Store')
        ->assertJsonPath('data.default_currency', 'USD')
        ->assertJsonPath('data.default_locale', 'de')
        ->assertJsonPath('data.timezone', 'Europe/Paris')
        ->assertJsonPath('data.settings_json.announcement.enabled', false)
        ->assertJsonPath('data.settings_json.checkout.terms_required', true)
        ->assertJsonPath('data.settings_json.notifications.sender_email', 'no-reply@shop.test');

    $settings = StoreSettings::query()->whereKey($store->getKey())->firstOrFail();

    expect($store->refresh()->name)->toBe('Acme API Store')
        ->and($store->default_currency)->toBe('USD')
        ->and($settings->settings_json['announcement']['text'])->toBe('API announcement')
        ->and($settings->settings_json['checkout']['terms_url'])->toBe('https://example.test/terms')
        ->and($settings->settings_json['notifications']['sender_email'])->toBe('no-reply@shop.test');
});

test('admin store settings api enforces token abilities and store scope', function (): void {
    $store = adminStoreSettingsApiStore();
    $otherStore = Store::factory()->create();
    $readToken = adminStoreSettingsApiToken($store, ['read-settings']);
    $writeToken = adminStoreSettingsApiToken($store, ['write-settings']);
    $otherStoreToken = adminStoreSettingsApiToken($otherStore, ['read-settings']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings")
        ->assertOk();

    $this->withToken($readToken['plain_text'])
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'name' => 'Read Only API Store',
        ])
        ->assertForbidden();

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

    $this->withToken($writeToken['plain_text'])
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'settings_json' => [
                'notifications' => [
                    'low_stock_threshold' => 12,
                ],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.settings_json.notifications.low_stock_threshold', 12);
});

test('admin store settings api validates defaults and settings shape', function (): void {
    $store = adminStoreSettingsApiStore();

    $this->actingAs(adminStoreSettingsApiUser())
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'default_currency' => 'BTC',
            'default_locale' => 'es',
            'timezone' => 'Mars/Olympus',
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['default_currency', 'default_locale', 'timezone']);

    $this->actingAs(adminStoreSettingsApiUser())
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'settings_json' => ['invalid-list-value'],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['settings_json']);

    $this->actingAs(adminStoreSettingsApiUser())
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'settings_json' => [
                'checkout' => [
                    'terms_url' => 'not-a-url',
                    'payment_hold_hours' => 0,
                ],
            ],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['settings_json.checkout.terms_url', 'settings_json.checkout.payment_hold_hours']);
});


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

use App\Models\Store;
use App\Models\StoreSettings;
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 adminStoreSettingsApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

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

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

test('admin store settings api shows and updates general settings', function (): void {
    $store = adminStoreSettingsApiStore();
    $user = adminStoreSettingsApiUser();

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings")
        ->assertOk()
        ->assertJsonPath('data.name', 'Acme Fashion')
        ->assertJsonPath('data.default_currency', 'EUR')
        ->assertJsonPath('data.settings_json.announcement.enabled', true)
        ->assertJsonPath('data.domains.0.is_primary', true);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'name' => 'Acme API Store',
            'default_currency' => 'USD',
            'default_locale' => 'de',
            'timezone' => 'Europe/Paris',
            'settings_json' => [
                'announcement' => [
                    'enabled' => false,
                    'text' => 'API announcement',
                ],
                'checkout' => [
                    'terms_required' => true,
                    'terms_url' => 'https://example.test/terms',
                ],
                'bank_transfer_cancel_days' => 10,
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.name', 'Acme API Store')
        ->assertJsonPath('data.default_currency', 'USD')
        ->assertJsonPath('data.default_locale', 'de')
        ->assertJsonPath('data.timezone', 'Europe/Paris')
        ->assertJsonPath('data.settings_json.announcement.enabled', false)
        ->assertJsonPath('data.settings_json.checkout.terms_required', true)
        ->assertJsonPath('data.settings_json.notifications.sender_email', 'no-reply@shop.test');

    $settings = StoreSettings::query()->whereKey($store->getKey())->firstOrFail();

    expect($store->refresh()->name)->toBe('Acme API Store')
        ->and($store->default_currency)->toBe('USD')
        ->and($settings->settings_json['announcement']['text'])->toBe('API announcement')
        ->and($settings->settings_json['checkout']['terms_url'])->toBe('https://example.test/terms')
        ->and($settings->settings_json['notifications']['sender_email'])->toBe('no-reply@shop.test');
});

test('admin store settings api enforces token abilities and store scope', function (): void {
    $store = adminStoreSettingsApiStore();
    $otherStore = Store::factory()->create();
    $readToken = adminStoreSettingsApiToken($store, ['read-settings']);
    $writeToken = adminStoreSettingsApiToken($store, ['write-settings']);
    $otherStoreToken = adminStoreSettingsApiToken($otherStore, ['read-settings']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/settings")
        ->assertOk();

    $this->withToken($readToken['plain_text'])
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'name' => 'Read Only API Store',
        ])
        ->assertForbidden();

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

    $this->withToken($writeToken['plain_text'])
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'settings_json' => [
                'notifications' => [
                    'low_stock_threshold' => 12,
                ],
            ],
        ])
        ->assertOk()
        ->assertJsonPath('data.settings_json.notifications.low_stock_threshold', 12);
});

test('admin store settings api validates defaults and settings shape', function (): void {
    $store = adminStoreSettingsApiStore();

    $this->actingAs(adminStoreSettingsApiUser())
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'default_currency' => 'BTC',
            'default_locale' => 'es',
            'timezone' => 'Mars/Olympus',
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['default_currency', 'default_locale', 'timezone']);

    $this->actingAs(adminStoreSettingsApiUser())
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'settings_json' => ['invalid-list-value'],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['settings_json']);

    $this->actingAs(adminStoreSettingsApiUser())
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/settings", [
            'settings_json' => [
                'checkout' => [
                    'terms_url' => 'not-a-url',
                    'payment_hold_hours' => 0,
                ],
            ],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['settings_json.checkout.terms_url', 'settings_json.checkout.payment_hold_hours']);
});


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

cmd: git diff --stat

aggregated_output:
 app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php | 2 +-
 routes/api.php                                                | 3 +++
 2 files changed, 4 insertions(+), 1 deletion(-)


output:
Chunk ID: 473b87
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 48
Output:
 app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php | 2 +-
 routes/api.php                                                | 3 +++
 2 files changed, 4 insertions(+), 1 deletion(-)


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

cmd: git status --short

aggregated_output:
 M app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php
 M routes/api.php
?? .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/StoreSettingsController.php
?? app/Http/Resources/Admin/V1/StoreSettingsResource.php
?? tests/Feature/Api/AdminStoreSettingsApiTest.php


output:
Chunk ID: 2ad684
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 106
Output:
 M app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php
 M routes/api.php
?? .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/StoreSettingsController.php
?? app/Http/Resources/Admin/V1/StoreSettingsResource.php
?? tests/Feature/Api/AdminStoreSettingsApiTest.php


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


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


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


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

aggregated_output:
  GET|HEAD  api/admin/v1/stores/{store}/settings api.admin.v1.settings.show  …
  PUT       api/admin/v1/stores/{store}/settings api.admin.v1.settings.update…

                                                            Showing [2] routes


output:
Chunk ID: dbe9fe
Wall time: 2.6108 seconds
Process exited with code 0
Original token count: 17
Output:

  .......

  Tests:    7 passed (64 assertions)
  Duration: 2.53s


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

aggregated_output:
  .......

  Tests:    7 passed (64 assertions)
  Duration: 2.53s


output:
Chunk ID: e59116
Wall time: 17.3895 seconds
Process exited with code 0
Original token count: 29
Output:

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

  Tests:    52 passed (428 assertions)
  Duration: 17.32s


output:
Chunk ID: eef029
Wall time: 0.0343 seconds
Process exited with code 0
Original token count: 61
Output:

  GET|HEAD  api/admin/v1/stores/{store}/settings api.admin.v1.settings.show  …
  PUT       api/admin/v1/stores/{store}/settings api.admin.v1.settings.update…

                                                            Showing [2] routes


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


message: The settings/theme focused tests, full API suite, and route list are green: settings now adds 2 admin API routes and the API suite is 52 tests. I’m running the whole suite again before committing this slice.


text: The settings/theme focused tests, full API suite, and route list are green: settings now adds 2 admin API routes and the API suite is 52 tests. I’m running the whole suite again before committing this slice.


output:
Chunk ID: 893213
Wall time: 30.0017 seconds
Process running with session ID 80024
Original token count: 21
Output:

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


output:
Chunk ID: ff026f
Wall time: 24.2823 seconds
Process exited with code 0
Original token count: 58
Output:
..........................................................................
  ............................................................................
  ...........

  Tests:    239 passed (1385 assertions)
  Duration: 61.21s


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

cmd: php artisan test --compact


message: The full suite is green again: 239 tests, 1385 assertions. I’m recording the settings API verification and narrowing the remaining REST gap to exports before committing.


text: The full suite is green again: 239 tests, 1385 assertions. I’m recording the settings API verification and narrowing the remaining REST gap to exports before committing.


status: completed

input:
*** Begin Patch
*** Update File: 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, collection CRUD, discount CRUD, content page CRUD, search status/reindex endpoints, analytics summary, shipping zone/rate settings, tax settings read/update, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, store settings read/update, search status/reindex endpoints, analytics summary, shipping zone/rate settings, tax settings read/update, theme upload/publish/settings update, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminThemeApiTest.php` passed after the admin theme API changes: 4 tests, 29 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin theme API changes: 49 tests, 393 assertions.
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores/{store}/themes --except-vendor` confirmed 3 theme API routes: upload/install, publish, and settings update.
 - 2026-05-04: `php artisan test --compact` passed after the admin theme API changes: 236 tests, 1350 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 nested JSON array validation, update-or-create settings patterns, JSON API validation assertions, and Pest 4 docs before the admin store settings API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/StoreSettingsController --no-interaction`, `php artisan make:resource Admin/V1/StoreSettingsResource --no-interaction`, and `php artisan make:test Api/AdminStoreSettingsApiTest --pest --no-interaction` created the general store settings API controller/resource and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin store settings API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminStoreSettingsApiTest.php` passed after the admin store settings API changes: 3 tests, 35 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminStoreSettingsApiTest.php tests/Feature/Api/AdminThemeApiTest.php` passed after the admin store settings API changes: 7 tests, 64 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin store settings API changes: 52 tests, 428 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores/{store}/settings --except-vendor` confirmed 2 general settings API routes: read and update.
+- 2026-05-04: `php artisan test --compact` passed after the admin store settings API changes: 239 tests, 1385 assertions.
@@
 - The theme upload API imports ZIP archives with `theme.json` or `manifest.json`, requires the same core template paths seeded for default themes, stores each imported file on the local disk under the new theme id, and seeds theme settings from the manifest or the store defaults.
 - The theme publish API validates required files before switching the store to exactly one published theme and flushing cached theme settings.
+- The store settings API exposes the general settings UI surface as `/api/admin/v1/stores/{store}/settings`: store name/currency/locale/timezone fields update the `stores` row, while `settings_json` patches are recursively merged into `store_settings` so checkout and notification defaults are preserved unless explicitly changed.
 - Checkout and notification settings persist in `store_settings.settings_json`; `bank_transfer_cancel_days` remains a root-level key because the existing cancellation job already consumes it from that location.
@@
-- Admin REST surfaces for general settings and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST export surfaces 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/collection/discount/content page/search/analytics/shipping/tax/theme/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/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 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, collection CRUD, discount CRUD, content page CRUD, search status/reindex endpoints, analytics summary, shipping zone/rate settings, tax settings read/update, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, store settings read/update, search status/reindex endpoints, analytics summary, shipping zone/rate settings, tax settings read/update, theme upload/publish/settings update, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
 | Admin UI | `specs/03-ADMIN-UI.md` | partial | Flux/Livewire admin shell now includes dashboard KPI/date-range reporting, analytics reporting with filters/export, catalog/product/collection/inventory management, product option matrix generation, product media upload/alt text/reorder/delete controls, order/customer/discount management, page index/form, nested navigation menu editor with drag sorting and parent selection, theme grid, theme settings editor with iframe preview, theme file list/editor, general settings, domain management, shipping zone/rate management, tax settings, checkout policy settings, notification settings, search settings/reindexing, installed app list/detail, and developer token/webhook management with auth protection and store scoping. |
@@ -389,2 +389,10 @@
 - 2026-05-04: `php artisan test --compact` passed after the admin theme API changes: 236 tests, 1350 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 nested JSON array validation, update-or-create settings patterns, JSON API validation assertions, and Pest 4 docs before the admin store settings API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/StoreSettingsController --no-interaction`, `php artisan make:resource Admin/V1/StoreSettingsResource --no-interaction`, and `php artisan make:test Api/AdminStoreSettingsApiTest --pest --no-interaction` created the general store settings API controller/resource and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin store settings API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminStoreSettingsApiTest.php` passed after the admin store settings API changes: 3 tests, 35 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminStoreSettingsApiTest.php tests/Feature/Api/AdminThemeApiTest.php` passed after the admin store settings API changes: 7 tests, 64 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin store settings API changes: 52 tests, 428 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores/{store}/settings --except-vendor` confirmed 2 general settings API routes: read and update.
+- 2026-05-04: `php artisan test --compact` passed after the admin store settings API changes: 239 tests, 1385 assertions.

@@ -432,2 +440,3 @@
 - The theme publish API validates required files before switching the store to exactly one published theme and flushing cached theme settings.
+- The store settings API exposes the general settings UI surface as `/api/admin/v1/stores/{store}/settings`: store name/currency/locale/timezone fields update the `stores` row, while `settings_json` patches are recursively merged into `store_settings` so checkout and notification defaults are preserved unless explicitly changed.
 - Checkout and notification settings persist in `store_settings.settings_json`; `bank_transfer_cancel_days` remains a root-level key because the existing cancellation job already consumes it from that location.
@@ -458,3 +467,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.
-- Admin REST surfaces for general settings and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST export surfaces 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.
@@ -464,2 +473,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/collection/discount/content page/search/analytics/shipping/tax/theme/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/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 specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


cmd: rm -f .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 && git status --short


command: rm -f .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 && git status --short

aggregated_output:
 M app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php
?? app/Http/Resources/Admin/V1/StoreSettingsResource.php
?? tests/Feature/Api/AdminStoreSettingsApiTest.php


output:
Chunk ID: 71f9ee
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 70
Output:
 M app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php
?? app/Http/Resources/Admin/V1/StoreSettingsResource.php
?? tests/Feature/Api/AdminStoreSettingsApiTest.php


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

cmd: git diff --check


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


cmd: git add app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php app/Http/Resources/Admin/V1/StoreSettingsResource.php routes/api.php specs/progress.md tests/Feature/Api/AdminStoreSettingsApiTest.php && git diff --cached --stat


command: git add app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php app/Http/Resources/Admin/V1/StoreSettingsResource.php routes/api.php specs/progress.md tests/Feature/Api/AdminStoreSettingsApiTest.php && git diff --cached --stat

aggregated_output:
 .../Api/Admin/V1/StoreSettingsController.php       | 152 +++++++++++++++++++++
 .../Api/Admin/V1/ThemeSettingsController.php       |   2 +-
 .../Resources/Admin/V1/StoreSettingsResource.php   |  42 ++++++
 routes/api.php                                     |   3 +
 specs/progress.md                                  |  15 +-
 tests/Feature/Api/AdminStoreSettingsApiTest.php    | 147 ++++++++++++++++++++
 6 files changed, 357 insertions(+), 4 deletions(-)


output:
Chunk ID: 040fd9
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 115
Output:
 .../Api/Admin/V1/StoreSettingsController.php       | 152 +++++++++++++++++++++
 .../Api/Admin/V1/ThemeSettingsController.php       |   2 +-
 .../Resources/Admin/V1/StoreSettingsResource.php   |  42 ++++++
 routes/api.php                                     |   3 +
 specs/progress.md                                  |  15 +-
 tests/Feature/Api/AdminStoreSettingsApiTest.php    | 147 ++++++++++++++++++++
 6 files changed, 357 insertions(+), 4 deletions(-)


aggregated_output:
A  app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php
M  app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php
A  app/Http/Resources/Admin/V1/StoreSettingsResource.php
M  routes/api.php
M  specs/progress.md
A  tests/Feature/Api/AdminStoreSettingsApiTest.php


output:
Chunk ID: c11e67
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 70
Output:
A  app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php
M  app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php
A  app/Http/Resources/Admin/V1/StoreSettingsResource.php
M  routes/api.php
M  specs/progress.md
A  tests/Feature/Api/AdminStoreSettingsApiTest.php


message: The staged settings slice is limited to the new store settings API, its resource/tests, routes/progress, and a small theme settings object-shape fix. I’m committing it now, then I’ll move to the export API gap.


text: The staged settings slice is limited to the new store settings API, its resource/tests, routes/progress, and a small theme settings object-shape fix. I’m committing it now, then I’ll move to the export API gap.


cmd: git commit -m "Add admin store settings API"


command: git commit -m "Add admin store settings API"

aggregated_output:
[2026-05-04-codex-5-5-goal b02a92c2] Add admin store settings API
 6 files changed, 357 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php
 create mode 100644 app/Http/Resources/Admin/V1/StoreSettingsResource.php
 create mode 100644 tests/Feature/Api/AdminStoreSettingsApiTest.php


output:
Chunk ID: 1271ba
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 86
Output:
[2026-05-04-codex-5-5-goal b02a92c2] Add admin store settings API
 6 files changed, 357 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php
 create mode 100644 app/Http/Resources/Admin/V1/StoreSettingsResource.php
 create mode 100644 tests/Feature/Api/AdminStoreSettingsApiTest.php


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


message: Committed `b02a92c2 Add admin store settings API`; the worktree is clean. I’m moving to the remaining export API surface now and checking the order/export specs plus existing analytics CSV patterns before choosing a storage model.


text: Committed `b02a92c2 Add admin store settings API`; the worktree is clean. I’m moving to the remaining export API surface now and checking the order/export specs plus existing analytics CSV patterns before choosing a storage model.


packages: laravel/framework pestphp/pest

queries: database model factory artisan make model migration