status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php
@@
 <?php

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

+use App\Enums\TaxMode;
 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\TaxSettingsResource;
+use App\Models\Store;
+use App\Models\TaxSettings;
 use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;

 class TaxSettingsController extends Controller
 {
-    //
+    public function show(Request $request, Store $store): TaxSettingsResource
+    {
+        $this->authorizeStore($request, $store);
+
+        return TaxSettingsResource::make($this->settings($store));
+    }
+
+    public function update(Request $request, Store $store): TaxSettingsResource
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $request->validate([
+            'mode' => ['required', Rule::in(array_map(fn (TaxMode $mode): string => $mode->value, TaxMode::cases()))],
+            'provider' => ['required_if:mode,provider', Rule::in(['none', 'stripe_tax'])],
+            'prices_include_tax' => ['required', 'boolean'],
+            'config_json' => ['required', 'array'],
+            'config_json.default_tax_rate' => ['nullable', 'integer', 'min:0', 'max:10000'],
+            'config_json.default_rate_bps' => ['nullable', 'integer', 'min:0', 'max:10000'],
+            'config_json.tax_rates' => ['nullable', 'array'],
+            'config_json.tax_rates.*.country_code' => ['required_with:config_json.tax_rates', 'string', 'size:2'],
+            'config_json.tax_rates.*.rate' => ['required_with:config_json.tax_rates', 'integer', 'min:0', 'max:10000'],
+            'config_json.tax_rates.*.name' => ['required_with:config_json.tax_rates', 'string', 'max:50'],
+            'config_json.tax_rates.*.shipping_taxed' => ['nullable', 'boolean'],
+            'config_json.rates' => ['nullable', 'array'],
+            'config_json.rates.*.country' => ['required_with:config_json.rates', 'string', 'size:2'],
+            'config_json.rates.*.rate_bps' => ['required_with:config_json.rates', 'integer', 'min:0', 'max:10000'],
+            'config_json.rates.*.name' => ['required_with:config_json.rates', 'string', 'max:50'],
+        ]);
+
+        $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(
+            ['store_id' => $store->getKey()],
+            [
+                'mode' => TaxMode::from($validated['mode']),
+                'provider' => $validated['mode'] === TaxMode::Provider->value ? $validated['provider'] : 'none',
+                'prices_include_tax' => (bool) $validated['prices_include_tax'],
+                'config_json' => $this->normalizeConfig($validated['config_json']),
+            ],
+        );
+
+        return TaxSettingsResource::make($settings->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): TaxSettings
+    {
+        return TaxSettings::withoutGlobalScopes()->firstOrCreate(
+            ['store_id' => $store->getKey()],
+            [
+                'mode' => TaxMode::Manual,
+                'provider' => 'none',
+                'prices_include_tax' => false,
+                'config_json' => [
+                    'name' => 'Tax',
+                    'default_rate_bps' => 0,
+                    'shipping_taxable' => true,
+                    'rates' => [],
+                ],
+            ],
+        );
+    }
+
+    /**
+     * @param  array<string, mixed>  $config
+     * @return array<string, mixed>
+     */
+    private function normalizeConfig(array $config): array
+    {
+        $rates = collect($config['tax_rates'] ?? $config['rates'] ?? [])
+            ->map(fn (array $rate): array => [
+                'country' => strtoupper((string) ($rate['country_code'] ?? $rate['country'])),
+                'rate_bps' => (int) ($rate['rate'] ?? $rate['rate_bps']),
+                'name' => (string) $rate['name'],
+                'shipping_taxed' => (bool) ($rate['shipping_taxed'] ?? true),
+            ])
+            ->values()
+            ->all();
+
+        return [
+            ...$config,
+            'default_rate_bps' => (int) ($config['default_tax_rate'] ?? $config['default_rate_bps'] ?? 0),
+            'shipping_taxable' => (bool) ($config['shipping_taxable'] ?? true),
+            'rates' => $rates,
+        ];
+    }
 }
*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/TaxSettingsResource.php
@@
 <?php

 namespace App\Http\Resources\Admin\V1;

+use Illuminate\Support\Arr;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;

 class TaxSettingsResource extends JsonResource
 {
@@
      */
     public function toArray(Request $request): array
     {
-        return parent::toArray($request);
+        return [
+            'store_id' => $this->store_id,
+            'mode' => $this->mode?->value,
+            'provider' => $this->provider,
+            'prices_include_tax' => $this->prices_include_tax,
+            'config_json' => $this->publicConfig(),
+            'updated_at' => null,
+        ];
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function publicConfig(): array
+    {
+        $config = $this->config_json ?? [];
+
+        if ($this->mode?->value === 'provider') {
+            return Arr::except($config, ['provider_api_key']);
+        }
+
+        return [
+            ...Arr::except($config, ['default_rate_bps', 'rates', 'provider_api_key']),
+            'default_tax_rate' => (int) data_get($config, 'default_tax_rate', data_get($config, 'default_rate_bps', 0)),
+            'tax_rates' => collect(data_get($config, 'tax_rates', data_get($config, 'rates', [])))
+                ->map(fn (array $rate): array => [
+                    'country_code' => strtoupper((string) data_get($rate, 'country_code', data_get($rate, 'country'))),
+                    'rate' => (int) data_get($rate, 'rate', data_get($rate, 'rate_bps', 0)),
+                    'name' => (string) data_get($rate, 'name', 'Tax'),
+                    'shipping_taxed' => (bool) data_get($rate, 'shipping_taxed', data_get($config, 'shipping_taxable', true)),
+                ])
+                ->values()
+                ->all(),
+        ];
     }
 }
*** Update File: /Users/fabianwesner/Herd/shop/routes/api.php
@@
 use App\Http\Controllers\Api\Admin\V1\SearchIndexController as AdminSearchIndexController;
+use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
 use App\Http\Controllers\Api\Apps\V1\DeferredEndpointController as DeferredAppEndpointController;
@@
         Route::middleware('admin.api:read-settings')->group(function (): void {
             Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
+            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('tax/settings', [AdminTaxSettingsController::class, 'update'])->name('tax.settings.update');
         });
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminTaxSettingsApiTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\TaxMode;
+use App\Models\Store;
+use App\Models\TaxSettings;
+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 adminTaxSettingsApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminTaxSettingsApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminTaxSettingsApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Tax settings integration', $abilities);
+}
+
+test('admin tax settings api shows and updates manual settings', function (): void {
+    $store = adminTaxSettingsApiStore();
+    $user = adminTaxSettingsApiUser();
+
+    $this->actingAs($user)
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings")
+        ->assertOk()
+        ->assertJsonPath('data.store_id', $store->getKey())
+        ->assertJsonPath('data.mode', 'manual')
+        ->assertJsonPath('data.config_json.default_tax_rate', 1900);
+
+    $this->actingAs($user)
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
+            'mode' => 'manual',
+            'provider' => 'none',
+            'prices_include_tax' => true,
+            'config_json' => [
+                'default_tax_rate' => 2100,
+                'tax_rates' => [
+                    [
+                        'country_code' => 'de',
+                        'rate' => 2100,
+                        'name' => 'VAT',
+                        'shipping_taxed' => true,
+                    ],
+                ],
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.prices_include_tax', true)
+        ->assertJsonPath('data.config_json.default_tax_rate', 2100)
+        ->assertJsonPath('data.config_json.tax_rates.0.country_code', 'DE');
+
+    $settings = TaxSettings::withoutGlobalScopes()->whereKey($store->getKey())->firstOrFail();
+
+    expect($settings->mode)->toBe(TaxMode::Manual)
+        ->and($settings->provider)->toBe('none')
+        ->and($settings->prices_include_tax)->toBeTrue()
+        ->and($settings->config_json['default_rate_bps'])->toBe(2100)
+        ->and($settings->config_json['rates'][0]['country'])->toBe('DE');
+});
+
+test('admin tax settings api enforces token abilities and store scope', function (): void {
+    $store = adminTaxSettingsApiStore();
+    $otherStore = Store::factory()->create();
+    $readToken = adminTaxSettingsApiToken($store, ['read-settings']);
+    $writeToken = adminTaxSettingsApiToken($store, ['write-settings']);
+    $otherStoreToken = adminTaxSettingsApiToken($otherStore, ['read-settings']);
+
+    $this->withToken($readToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings")
+        ->assertOk();
+
+    $this->withToken($readToken['plain_text'])
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
+            'mode' => 'manual',
+            'provider' => 'none',
+            'prices_include_tax' => false,
+            'config_json' => [],
+        ])
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings")
+        ->assertForbidden();
+
+    $this->withToken($writeToken['plain_text'])
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
+            'mode' => 'provider',
+            'provider' => 'stripe_tax',
+            'prices_include_tax' => false,
+            'config_json' => [
+                'stripe_tax_settings_id' => 'txr_api',
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.mode', 'provider')
+        ->assertJsonPath('data.provider', 'stripe_tax');
+});
+
+test('admin tax settings api validates provider and rate payloads', function (): void {
+    $store = adminTaxSettingsApiStore();
+
+    $this->actingAs(adminTaxSettingsApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
+            'mode' => 'provider',
+            'prices_include_tax' => false,
+            'config_json' => [],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['provider']);
+
+    $this->actingAs(adminTaxSettingsApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
+            'mode' => 'manual',
+            'provider' => 'none',
+            'prices_include_tax' => false,
+            'config_json' => [
+                'tax_rates' => [
+                    [
+                        'country_code' => 'DEU',
+                        'rate' => 12000,
+                        'name' => 'VAT',
+                    ],
+                ],
+            ],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['config_json.tax_rates.0.country_code', 'config_json.tax_rates.0.rate']);
 });
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php
M /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/TaxSettingsResource.php
M /Users/fabianwesner/Herd/shop/routes/api.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminTaxSettingsApiTest.php

type: update

unified_diff:
@@ -12,2 +12,3 @@
 use App\Http\Controllers\Api\Admin\V1\SearchIndexController as AdminSearchIndexController;
+use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
 use App\Http\Controllers\Api\Apps\V1\DeferredEndpointController as DeferredAppEndpointController;
@@ -101,2 +102,3 @@
             Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
+            Route::get('tax/settings', [AdminTaxSettingsController::class, 'show'])->name('tax.settings.show');
         });
@@ -105,2 +107,3 @@
             Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
+            Route::put('tax/settings', [AdminTaxSettingsController::class, 'update'])->name('tax.settings.update');
         });

unified_diff:
@@ -4,4 +4,9 @@

+use App\Enums\TaxMode;
 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\TaxSettingsResource;
+use App\Models\Store;
+use App\Models\TaxSettings;
 use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;

@@ -9,3 +14,94 @@
 {
-    //
+    public function show(Request $request, Store $store): TaxSettingsResource
+    {
+        $this->authorizeStore($request, $store);
+
+        return TaxSettingsResource::make($this->settings($store));
+    }
+
+    public function update(Request $request, Store $store): TaxSettingsResource
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $request->validate([
+            'mode' => ['required', Rule::in(array_map(fn (TaxMode $mode): string => $mode->value, TaxMode::cases()))],
+            'provider' => ['required_if:mode,provider', Rule::in(['none', 'stripe_tax'])],
+            'prices_include_tax' => ['required', 'boolean'],
+            'config_json' => ['required', 'array'],
+            'config_json.default_tax_rate' => ['nullable', 'integer', 'min:0', 'max:10000'],
+            'config_json.default_rate_bps' => ['nullable', 'integer', 'min:0', 'max:10000'],
+            'config_json.tax_rates' => ['nullable', 'array'],
+            'config_json.tax_rates.*.country_code' => ['required_with:config_json.tax_rates', 'string', 'size:2'],
+            'config_json.tax_rates.*.rate' => ['required_with:config_json.tax_rates', 'integer', 'min:0', 'max:10000'],
+            'config_json.tax_rates.*.name' => ['required_with:config_json.tax_rates', 'string', 'max:50'],
+            'config_json.tax_rates.*.shipping_taxed' => ['nullable', 'boolean'],
+            'config_json.rates' => ['nullable', 'array'],
+            'config_json.rates.*.country' => ['required_with:config_json.rates', 'string', 'size:2'],
+            'config_json.rates.*.rate_bps' => ['required_with:config_json.rates', 'integer', 'min:0', 'max:10000'],
+            'config_json.rates.*.name' => ['required_with:config_json.rates', 'string', 'max:50'],
+        ]);
+
+        $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(
+            ['store_id' => $store->getKey()],
+            [
+                'mode' => TaxMode::from($validated['mode']),
+                'provider' => $validated['mode'] === TaxMode::Provider->value ? $validated['provider'] : 'none',
+                'prices_include_tax' => (bool) $validated['prices_include_tax'],
+                'config_json' => $this->normalizeConfig($validated['config_json']),
+            ],
+        );
+
+        return TaxSettingsResource::make($settings->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): TaxSettings
+    {
+        return TaxSettings::withoutGlobalScopes()->firstOrCreate(
+            ['store_id' => $store->getKey()],
+            [
+                'mode' => TaxMode::Manual,
+                'provider' => 'none',
+                'prices_include_tax' => false,
+                'config_json' => [
+                    'name' => 'Tax',
+                    'default_rate_bps' => 0,
+                    'shipping_taxable' => true,
+                    'rates' => [],
+                ],
+            ],
+        );
+    }
+
+    /**
+     * @param  array<string, mixed>  $config
+     * @return array<string, mixed>
+     */
+    private function normalizeConfig(array $config): array
+    {
+        $rates = collect($config['tax_rates'] ?? $config['rates'] ?? [])
+            ->map(fn (array $rate): array => [
+                'country' => strtoupper((string) ($rate['country_code'] ?? $rate['country'])),
+                'rate_bps' => (int) ($rate['rate'] ?? $rate['rate_bps']),
+                'name' => (string) $rate['name'],
+                'shipping_taxed' => (bool) ($rate['shipping_taxed'] ?? true),
+            ])
+            ->values()
+            ->all();
+
+        return [
+            ...$config,
+            'default_rate_bps' => (int) ($config['default_tax_rate'] ?? $config['default_rate_bps'] ?? 0),
+            'shipping_taxable' => (bool) ($config['shipping_taxable'] ?? true),
+            'rates' => $rates,
+        ];
+    }
 }

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

+use Illuminate\Support\Arr;
 use Illuminate\Http\Request;
@@ -16,3 +17,36 @@
     {
-        return parent::toArray($request);
+        return [
+            'store_id' => $this->store_id,
+            'mode' => $this->mode?->value,
+            'provider' => $this->provider,
+            'prices_include_tax' => $this->prices_include_tax,
+            'config_json' => $this->publicConfig(),
+            'updated_at' => null,
+        ];
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function publicConfig(): array
+    {
+        $config = $this->config_json ?? [];
+
+        if ($this->mode?->value === 'provider') {
+            return Arr::except($config, ['provider_api_key']);
+        }
+
+        return [
+            ...Arr::except($config, ['default_rate_bps', 'rates', 'provider_api_key']),
+            'default_tax_rate' => (int) data_get($config, 'default_tax_rate', data_get($config, 'default_rate_bps', 0)),
+            'tax_rates' => collect(data_get($config, 'tax_rates', data_get($config, 'rates', [])))
+                ->map(fn (array $rate): array => [
+                    'country_code' => strtoupper((string) data_get($rate, 'country_code', data_get($rate, 'country'))),
+                    'rate' => (int) data_get($rate, 'rate', data_get($rate, 'rate_bps', 0)),
+                    'name' => (string) data_get($rate, 'name', 'Tax'),
+                    'shipping_taxed' => (bool) data_get($rate, 'shipping_taxed', data_get($config, 'shipping_taxable', true)),
+                ])
+                ->values()
+                ->all(),
+        ];
     }

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

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\TaxMode;
+use App\Models\Store;
+use App\Models\TaxSettings;
+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 adminTaxSettingsApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminTaxSettingsApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminTaxSettingsApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Tax settings integration', $abilities);
+}
+
+test('admin tax settings api shows and updates manual settings', function (): void {
+    $store = adminTaxSettingsApiStore();
+    $user = adminTaxSettingsApiUser();
+
+    $this->actingAs($user)
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings")
+        ->assertOk()
+        ->assertJsonPath('data.store_id', $store->getKey())
+        ->assertJsonPath('data.mode', 'manual')
+        ->assertJsonPath('data.config_json.default_tax_rate', 1900);
+
+    $this->actingAs($user)
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
+            'mode' => 'manual',
+            'provider' => 'none',
+            'prices_include_tax' => true,
+            'config_json' => [
+                'default_tax_rate' => 2100,
+                'tax_rates' => [
+                    [
+                        'country_code' => 'de',
+                        'rate' => 2100,
+                        'name' => 'VAT',
+                        'shipping_taxed' => true,
+                    ],
+                ],
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.prices_include_tax', true)
+        ->assertJsonPath('data.config_json.default_tax_rate', 2100)
+        ->assertJsonPath('data.config_json.tax_rates.0.country_code', 'DE');
+
+    $settings = TaxSettings::withoutGlobalScopes()->whereKey($store->getKey())->firstOrFail();
+
+    expect($settings->mode)->toBe(TaxMode::Manual)
+        ->and($settings->provider)->toBe('none')
+        ->and($settings->prices_include_tax)->toBeTrue()
+        ->and($settings->config_json['default_rate_bps'])->toBe(2100)
+        ->and($settings->config_json['rates'][0]['country'])->toBe('DE');
+});
+
+test('admin tax settings api enforces token abilities and store scope', function (): void {
+    $store = adminTaxSettingsApiStore();
+    $otherStore = Store::factory()->create();
+    $readToken = adminTaxSettingsApiToken($store, ['read-settings']);
+    $writeToken = adminTaxSettingsApiToken($store, ['write-settings']);
+    $otherStoreToken = adminTaxSettingsApiToken($otherStore, ['read-settings']);
+
+    $this->withToken($readToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings")
+        ->assertOk();
+
+    $this->withToken($readToken['plain_text'])
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
+            'mode' => 'manual',
+            'provider' => 'none',
+            'prices_include_tax' => false,
+            'config_json' => [],
+        ])
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings")
+        ->assertForbidden();
+
+    $this->withToken($writeToken['plain_text'])
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
+            'mode' => 'provider',
+            'provider' => 'stripe_tax',
+            'prices_include_tax' => false,
+            'config_json' => [
+                'stripe_tax_settings_id' => 'txr_api',
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.mode', 'provider')
+        ->assertJsonPath('data.provider', 'stripe_tax');
+});
+
+test('admin tax settings api validates provider and rate payloads', function (): void {
+    $store = adminTaxSettingsApiStore();
+
+    $this->actingAs(adminTaxSettingsApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
+            'mode' => 'provider',
+            'prices_include_tax' => false,
+            'config_json' => [],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['provider']);
+
+    $this->actingAs(adminTaxSettingsApiUser())
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/tax/settings", [
+            'mode' => 'manual',
+            'provider' => 'none',
+            'prices_include_tax' => false,
+            'config_json' => [
+                'tax_rates' => [
+                    [
+                        'country_code' => 'DEU',
+                        'rate' => 12000,
+                        'name' => 'VAT',
+                    ],
+                ],
+            ],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['config_json.tax_rates.0.country_code', 'config_json.tax_rates.0.rate']);
 });

status: completed


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php\nM /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/TaxSettingsResource.php\nM /Users/fabianwesner/Herd/shop/routes/api.php\nM /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminTaxSettingsApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: Tax settings API is patched. I’m formatting, then running the focused test to verify the public/internal config translation and validation rules.

phase: commentary


type: message

role: assistant

text: Tax settings API is patched. I’m formatting, then running the focused test to verify the public/internal config translation and validation rules.


output:
Chunk ID: 7c7039
Wall time: 0.3299 seconds
Process exited with code 0
Original token count: 32
Output:
{"result":"fixed","files":[{"path":"app\/Http\/Resources\/Admin\/V1\/TaxSettingsResource.php","fixers":["ordered_imports"]}]}


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

type: unknown

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

result: fixed


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


output:
Chunk ID: 1ebd0c
Wall time: 1.0021 seconds
Process running with session ID 74099
Original token count: 0
Output:


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

aggregated_output:
  .⨯.
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Api\AdminTaxSettingsApiTest > admin tax settings ap…
  Expected response status code [200] but received 500.
Failed asserting that 500 is identical to 200.

The following exception occurred during the last request:

ErrorException: Undefined array key "config_json" in /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php:50
Stack trace:
#0 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php(258): Illuminate\Foundation\Bootstrap\HandleExceptions->handleError(2, 'Undefined array...', '/Users/fabianwe...', 50)
#1 /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php(50): Illuminate\Foundation\Bootstrap\HandleExceptions->{closure:Illuminate\Foundation\Bootstrap\HandleExceptions::forwardsTo():257}(2, 'Undefined array...', '/Users/fabianwe...', 50)
#2 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(46): App\Http\Controllers\Api\Admin\V1\TaxSettingsController->update(Object(Illuminate\Http\Request), Object(App\Models\Store))
#3 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Route.php(265): Illuminate\Routing\ControllerDispatcher->dispatch(Object(Illuminate\Routing\Route), Object(App\Http\Controllers\Api\Admin\V1\TaxSettingsController), 'update')
#4 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Route.php(211): Illuminate\Routing\Route->runController()
#5 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Router.php(822): Illuminate\Routing\Route->run()
#6 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\Routing\Router->{closure:Illuminate\Routing\Router::runRouteWithinStack():821}(Object(Illuminate\Http\Request))
#7 /Users/fabianwesner/Herd/shop/app/Http/Middleware/AuthenticateAdminApi.php(66): Illuminate\Pipeline\Pipeline->{closure:Illuminate\Pipeline\Pipeline::prepareDestination():178}(Object(Illuminate\Http\Request))
#8 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): App\Http\Middleware\AuthenticateAdminApi->handle(Object(Illuminate\Http\Request), Object(Closure), 'write-settings')
#9 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Middleware/SubstituteBindings.php(50): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#10 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Routing\Middleware\SubstituteBindings->handle(Object(Illuminate\Http\Request), Object(Closure))
#11 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Middleware/ThrottleRequests.php(166): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#12 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Middleware/ThrottleRequests.php(93): Illuminate\Routing\Middleware\ThrottleRequests->handleRequest(Object(Illuminate\Http\Request), Object(Closure), Array)
#13 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Routing\Middleware\ThrottleRequests->handle(Object(Illuminate\Http\Request), Object(Closure), '60', '1')
#14 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(137): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#15 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Router.php(821): Illuminate\Pipeline\Pipeline->then(Object(Closure))
#16 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Router.php(800): Illuminate\Routing\Router->runRouteWithinStack(Object(Illuminate\Routing\Route), Object(Illuminate\Http\Request))
#17 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Router.php(764): Illuminate\Routing\Router->runRoute(Object(Illuminate\Http\Request), Object(Illuminate\Routing\Route))
#18 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Router.php(753): Illuminate\Routing\Router->dispatchToRoute(Object(Illuminate\Http\Request))
#19 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(200): Illuminate\Routing\Router->dispatch(Object(Illuminate\Http\Request))
#20 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\Foundat…4600 chars truncated…(Object(Illuminate\Http\Request))
#40 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Http\Middleware\ValidatePathEncoding->handle(Object(Illuminate\Http\Request), Object(Closure))
#41 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(137): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#42 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(175): Illuminate\Pipeline\Pipeline->then(Object(Closure))
#43 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(144): Illuminate\Foundation\Http\Kernel->sendRequestThroughRouter(Object(Illuminate\Http\Request))
#44 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php(607): Illuminate\Foundation\Http\Kernel->handle(Object(Illuminate\Http\Request))
#45 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php(573): Illuminate\Foundation\Testing\TestCase->call('PUT', '/api/admin/v1/s...', Array, Array, Array, Array, '{"mode":"provid...')
#46 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php(441): Illuminate\Foundation\Testing\TestCase->json('PUT', '/api/admin/v1/s...', Array, Array, 0)
#47 /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminTaxSettingsApiTest.php(104): Illuminate\Foundation\Testing\TestCase->putJson('/api/admin/v1/s...', Array)
#48 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Factories/TestCaseMethodFactory.php(168): P\Tests\Feature\Api\AdminTaxSettingsApiTest->{closure:/Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminTaxSettingsApiTest.php:79}()
#49 [internal function]: P\Tests\Feature\Api\AdminTaxSettingsApiTest->{closure:Pest\Factories\TestCaseMethodFactory::getClosure():158}()
#50 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Concerns/Testable.php(429): call_user_func_array(Object(Closure), Array)
#51 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Support/ExceptionTrace.php(26): P\Tests\Feature\Api\AdminTaxSettingsApiTest->{closure:Pest\Concerns\Testable::__callClosure():429}()
#52 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Concerns/Testable.php(429): Pest\Support\ExceptionTrace::ensure(Object(Closure))
#53 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Concerns/Testable.php(331): P\Tests\Feature\Api\AdminTaxSettingsApiTest->__callClosure(Object(Closure), Array)
#54 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Factories/TestCaseFactory.php(169) : eval()'d code(26): P\Tests\Feature\Api\AdminTaxSettingsApiTest->__runTest(Object(Closure))
#55 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/Framework/TestCase.php(1332): P\Tests\Feature\Api\AdminTaxSettingsApiTest->__pest_evaluable_admin_tax_settings_api_enforces_token_abilities_and_store_scope()
#56 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/Framework/TestCase.php(519): PHPUnit\Framework\TestCase->runTest()
#57 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/Framework/TestRunner/TestRunner.php(99): PHPUnit\Framework\TestCase->runBare()
#58 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/Framework/TestCase.php(359): PHPUnit\Framework\TestRunner->run(Object(P\Tests\Feature\Api\AdminTaxSettingsApiTest))
#59 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/Framework/TestSuite.php(374): PHPUnit\Framework\TestCase->run()
#60 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/TextUI/TestRunner.php(64): PHPUnit\Framework\TestSuite->run()
#61 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/TextUI/Application.php(229): PHPUnit\TextUI\TestRunner->run(Object(PHPUnit\TextUI\Configuration\Configuration), Object(PHPUnit\Runner\ResultCache\DefaultResultCache), Object(PHPUnit\Framework\TestSuite))
#62 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Kernel.php(103): PHPUnit\TextUI\Application->run(Array)
#63 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/bin/pest(184): Pest\Kernel->handle(Array, Array)
#64 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/bin/pest(192): {closure:/Users/fabianwesner/Herd/shop/vendor/pestphp/pest/bin/pest:18}()
#65 {main}

----------------------------------------------------------------------------------

Undefined array key "config_json"

  at tests/Feature/Api/AdminTaxSettingsApiTest.php:112
    108▕             'config_json' => [
    109▕                 'stripe_tax_settings_id' => 'txr_api',
    110▕             ],
    111▕         ])
  ➜ 112▕         ->assertOk()
    113▕         ->assertJsonPath('data.mode', 'provider')
    114▕         ->assertJsonPath('data.provider', 'stripe_tax');
    115▕ });
    116▕

  Tests:    1 failed, 2 passed (24 assertions)
  Duration: 1.26s

status: failed


output:
Chunk ID: bde220
Wall time: 0.0000 seconds
Process exited with code 1
Original token count: 3650
Output:

  .⨯.
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Api\AdminTaxSettingsApiTest > admin tax settings ap…
  Expected response status code [200] but received 500.
Failed asserting that 500 is identical to 200.

The following exception occurred during the last request:

ErrorException: Undefined array key "config_json" in /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php:50
Stack trace:
#0 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php(258): Illuminate\Foundation\Bootstrap\HandleExceptions->handleError(2, 'Undefined array...', '/Users/fabianwe...', 50)
#1 /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php(50): Illuminate\Foundation\Bootstrap\HandleExceptions->{closure:Illuminate\Foundation\Bootstrap\HandleExceptions::forwardsTo():257}(2, 'Undefined array...', '/Users/fabianwe...', 50)
#2 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(46): App\Http\Controllers\Api\Admin\V1\TaxSettingsController->update(Object(Illuminate\Http\Request), Object(App\Models\Store))
#3 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Route.php(265): Illuminate\Routing\ControllerDispatcher->dispatch(Object(Illuminate\Routing\Route), Object(App\Http\Controllers\Api\Admin\V1\TaxSettingsController), 'update')
#4 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Route.php(211): Illuminate\Routing\Route->runController()
#5 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Router.php(822): Illuminate\Routing\Route->run()
#6 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\Routing\Router->{closure:Illuminate\Routing\Router::runRouteWithinStack():821}(Object(Illuminate\Http\Request))
#7 /Users/fabianwesner/Herd/shop/app/Http/Middleware/AuthenticateAdminApi.php(66): Illuminate\Pipeline\Pipeline->{closure:Illuminate\Pipeline\Pipeline::prepareDestination():178}(Object(Illuminate\Http\Request))
#8 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): App\Http\Middleware\AuthenticateAdminApi->handle(Object(Illuminate\Http\Request), Object(Closure), 'write-settings')
#9 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Middleware/SubstituteBindings.php(50): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#10 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Routing\Middleware\SubstituteBindings->handle(Object(Illuminate\Http\Request), Object(Closure))
#11 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Middleware/ThrottleRequests.php(166): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#12 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Middleware/ThrottleRequests.php(93): Illuminate\Routing\Middleware\ThrottleRequests->handleRequest(Object(Illuminate\Http\Request), Object(Closure), Array)
#13 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Routing\Middleware\ThrottleRequests->handle(Object(Illuminate\Http\Request), Object(Closure), '60', '1')
#14 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(137): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#15 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Router.php(821): Illuminate\Pipeline\Pipeline->then(Object(Closure))
#16 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Router.php(800): Illuminate\Routing\Router->runRouteWithinStack(Object(Illuminate\Routing\Route), Object(Illuminate\Http\Request))
#17 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Router.php(764): Illuminate\Routing\Router->runRoute(Object(Illuminate\Http\Request), Object(Illuminate\Routing\Route))
#18 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Routing/Router.php(753): Illuminate\Routing\Router->dispatchToRoute(Object(Illuminate\Http\Request))
#19 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(200): Illuminate\Routing\Router->dispatch(Object(Illuminate\Http\Request))
#20 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\Foundation\Http\Kernel->{closure:Illuminate\Foundation\Http\Kernel::dispatchToRouter():197}(Object(Illuminate\Http\Request))
#21 /Users/fabianwesner/Herd/shop/vendor/livewire/livewire/src/Features/SupportDisablingBackButtonCache/DisableBackButtonCacheMiddleware.php(19): Illuminate\Pipeline\Pipeline->{closure:Illuminate\Pipeline\Pipeline::prepareDestination():178}(Object(Illuminate\Http\Request))
#22 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Livewire\Features\SupportDisablingBackButtonCache\DisableBackButtonCacheMiddleware->handle(Object(Illuminate\Http\Request), Object(Closure))
#23 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php(21): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#24 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/ConvertEmptyStringsToNull.php(31): Illuminate\Foundation\Http\Middleware\TransformsRequest->handle(Object(Illuminate\Http\Request), Object(Closure))
#25 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull->handle(Object(Illuminate\Http\Request), Object(Closure))
#26 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php(21): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#27 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php(51): Illuminate\Foundation\Http\Middleware\TransformsRequest->handle(Object(Illuminate\Http\Request), Object(Closure))
#28 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Foundation\Http\Middleware\TrimStrings->handle(Object(Illuminate\Http\Request), Object(Closure))
#29 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Http/Middleware/ValidatePostSize.php(27): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#30 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Http\Middleware\ValidatePostSize->handle(Object(Illuminate\Http\Request), Object(Closure))
#31 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php(109): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#32 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance->handle(Object(Illuminate\Http\Request), Object(Closure))
#33 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Http/Middleware/HandleCors.php(74): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#34 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Http\Middleware\HandleCors->handle(Object(Illuminate\Http\Request), Object(Closure))
#35 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Http/Middleware/TrustProxies.php(58): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#36 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Http\Middleware\TrustProxies->handle(Object(Illuminate\Http\Request), Object(Closure))
#37 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/InvokeDeferredCallbacks.php(22): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#38 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks->handle(Object(Illuminate\Http\Request), Object(Closure))
#39 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Http/Middleware/ValidatePathEncoding.php(26): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#40 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(219): Illuminate\Http\Middleware\ValidatePathEncoding->handle(Object(Illuminate\Http\Request), Object(Closure))
#41 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(137): Illuminate\Pipeline\Pipeline->{closure:{closure:Illuminate\Pipeline\Pipeline::carry():194}:195}(Object(Illuminate\Http\Request))
#42 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(175): Illuminate\Pipeline\Pipeline->then(Object(Closure))
#43 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(144): Illuminate\Foundation\Http\Kernel->sendRequestThroughRouter(Object(Illuminate\Http\Request))
#44 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php(607): Illuminate\Foundation\Http\Kernel->handle(Object(Illuminate\Http\Request))
#45 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php(573): Illuminate\Foundation\Testing\TestCase->call('PUT', '/api/admin/v1/s...', Array, Array, Array, Array, '{"mode":"provid...')
#46 /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php(441): Illuminate\Foundation\Testing\TestCase->json('PUT', '/api/admin/v1/s...', Array, Array, 0)
#47 /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminTaxSettingsApiTest.php(104): Illuminate\Foundation\Testing\TestCase->putJson('/api/admin/v1/s...', Array)
#48 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Factories/TestCaseMethodFactory.php(168): P\Tests\Feature\Api\AdminTaxSettingsApiTest->{closure:/Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminTaxSettingsApiTest.php:79}()
#49 [internal function]: P\Tests\Feature\Api\AdminTaxSettingsApiTest->{closure:Pest\Factories\TestCaseMethodFactory::getClosure():158}()
#50 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Concerns/Testable.php(429): call_user_func_array(Object(Closure), Array)
#51 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Support/ExceptionTrace.php(26): P\Tests\Feature\Api\AdminTaxSettingsApiTest->{closure:Pest\Concerns\Testable::__callClosure():429}()
#52 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Concerns/Testable.php(429): Pest\Support\ExceptionTrace::ensure(Object(Closure))
#53 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Concerns/Testable.php(331): P\Tests\Feature\Api\AdminTaxSettingsApiTest->__callClosure(Object(Closure), Array)
#54 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Factories/TestCaseFactory.php(169) : eval()'d code(26): P\Tests\Feature\Api\AdminTaxSettingsApiTest->__runTest(Object(Closure))
#55 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/Framework/TestCase.php(1332): P\Tests\Feature\Api\AdminTaxSettingsApiTest->__pest_evaluable_admin_tax_settings_api_enforces_token_abilities_and_store_scope()
#56 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/Framework/TestCase.php(519): PHPUnit\Framework\TestCase->runTest()
#57 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/Framework/TestRunner/TestRunner.php(99): PHPUnit\Framework\TestCase->runBare()
#58 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/Framework/TestCase.php(359): PHPUnit\Framework\TestRunner->run(Object(P\Tests\Feature\Api\AdminTaxSettingsApiTest))
#59 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/Framework/TestSuite.php(374): PHPUnit\Framework\TestCase->run()
#60 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/TextUI/TestRunner.php(64): PHPUnit\Framework\TestSuite->run()
#61 /Users/fabianwesner/Herd/shop/vendor/phpunit/phpunit/src/TextUI/Application.php(229): PHPUnit\TextUI\TestRunner->run(Object(PHPUnit\TextUI\Configuration\Configuration), Object(PHPUnit\Runner\ResultCache\DefaultResultCache), Object(PHPUnit\Framework\TestSuite))
#62 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/src/Kernel.php(103): PHPUnit\TextUI\Application->run(Array)
#63 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/bin/pest(184): Pest\Kernel->handle(Array, Array)
#64 /Users/fabianwesner/Herd/shop/vendor/pestphp/pest/bin/pest(192): {closure:/Users/fabianwesner/Herd/shop/vendor/pestphp/pest/bin/pest:18}()
#65 {main}

----------------------------------------------------------------------------------

Undefined array key "config_json"

  at tests/Feature/Api/AdminTaxSettingsApiTest.php:112
    108▕             'config_json' => [
    109▕                 'stripe_tax_settings_id' => 'txr_api',
    110▕             ],
    111▕         ])
  ➜ 112▕         ->assertOk()
    113▕         ->assertJsonPath('data.mode', 'provider')
    114▕         ->assertJsonPath('data.provider', 'stripe_tax');
    115▕ });
    116▕

  Tests:    1 failed, 2 passed (24 assertions)
  Duration: 1.26s


message: The focused test exposed a Laravel validated-data nuance: provider-specific `config_json` keys without nested rules were validated as an array but not returned in `$validated`. I’m switching the config normalization to read the already-validated request array directly.


text: The focused test exposed a Laravel validated-data nuance: provider-specific `config_json` keys without nested rules were validated as an array but not returned in `$validated`. I’m switching the config normalization to read the already-validated request array directly.


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php
@@
         $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(
             ['store_id' => $store->getKey()],
             [
                 'mode' => TaxMode::from($validated['mode']),
                 'provider' => $validated['mode'] === TaxMode::Provider->value ? $validated['provider'] : 'none',
                 'prices_include_tax' => (bool) $validated['prices_include_tax'],
-                'config_json' => $this->normalizeConfig($validated['config_json']),
+                'config_json' => $this->normalizeConfig($request->input('config_json', [])),
             ],
         );
*** End Patch


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

unified_diff:
@@ -49,3 +49,3 @@
                 'prices_include_tax' => (bool) $validated['prices_include_tax'],
-                'config_json' => $this->normalizeConfig($validated['config_json']),
+                'config_json' => $this->normalizeConfig($request->input('config_json', [])),
             ],


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


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


result: pass


output:
Chunk ID: 1ca00f
Wall time: 1.0012 seconds
Process running with session ID 81570
Original token count: 0
Output:


aggregated_output:
  ...

  Tests:    3 passed (26 assertions)
  Duration: 1.25s


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

  ...

  Tests:    3 passed (26 assertions)
  Duration: 1.25s


message: The tax settings focused test is green now: 3 tests, 26 assertions. I’m running the API group and route-list check.


text: The tax settings focused test is green now: 3 tests, 26 assertions. I’m running the API group and route-list check.


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


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


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

aggregated_output:
  GET|HEAD  api/admin/v1/stores/{store}/analytics/summary api.admin.v1.analyt…
  GET|HEAD  api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  POST      api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  PUT       api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  DELETE    api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  GET|HEAD  api/admin/v1/stores/{store}/customers api.admin.v1.customers.inde…
  GET|HEAD  api/admin/v1/stores/{store}/customers/{customer} api.admin.v1.cus…
  GET|HEAD  api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.inde…
  POST      api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.stor…
  PUT       api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  DELETE    api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  GET|HEAD  api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › Ap…
  GET|HEAD  api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.sh…
  POST      api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admin…
  POST      api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.o…
  GET|HEAD  api/admin/v1/stores/{store}/pages api.admin.v1.pages.index › Api\…
  POST      api/admin/v1/stores/{store}/pages api.admin.v1.pages.store › Api\…
  PUT       api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.updat…
  DELETE    api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.destr…
  GET|HEAD  api/admin/v1/stores/{store}/products api.admin.v1.products.index …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  POST      api/admin/v1/stores/{store}/search/reindex api.admin.v1.search.re…
  GET|HEAD  api/admin/v1/stores/{store}/search/status api.admin.v1.search.sta…
  GET|HEAD  api/admin/v1/stores/{store}/tax/settings api.admin.v1.tax.setting…
  PUT       api/admin/v1/stores/{store}/tax/settings api.admin.v1.tax.setting…

                                                           Showing [25] routes


output:
Chunk ID: fcd70a
Wall time: 1.0014 seconds
Process running with session ID 52062
Original token count: 0
Output:


output:
Chunk ID: 36f105
Wall time: 0.1778 seconds
Process exited with code 0
Original token count: 529
Output:

  GET|HEAD  api/admin/v1/stores/{store}/analytics/summary api.admin.v1.analyt…
  GET|HEAD  api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  POST      api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  PUT       api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  DELETE    api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  GET|HEAD  api/admin/v1/stores/{store}/customers api.admin.v1.customers.inde…
  GET|HEAD  api/admin/v1/stores/{store}/customers/{customer} api.admin.v1.cus…
  GET|HEAD  api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.inde…
  POST      api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.stor…
  PUT       api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  DELETE    api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  GET|HEAD  api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › Ap…
  GET|HEAD  api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.sh…
  POST      api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admin…
  POST      api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.o…
  GET|HEAD  api/admin/v1/stores/{store}/pages api.admin.v1.pages.index › Api\…
  POST      api/admin/v1/stores/{store}/pages api.admin.v1.pages.store › Api\…
  PUT       api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.updat…
  DELETE    api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.destr…
  GET|HEAD  api/admin/v1/stores/{store}/products api.admin.v1.products.index …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  POST      api/admin/v1/stores/{store}/search/reindex api.admin.v1.search.re…
  GET|HEAD  api/admin/v1/stores/{store}/search/status api.admin.v1.search.sta…
  GET|HEAD  api/admin/v1/stores/{store}/tax/settings api.admin.v1.tax.setting…
  PUT       api/admin/v1/stores/{store}/tax/settings api.admin.v1.tax.setting…

                                                           Showing [25] routes


output:
Chunk ID: 6686bc
Wall time: 5.0017 seconds
Process running with session ID 52062
Original token count: 10
Output:

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


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


output:
Chunk ID: 3d60c6
Wall time: 0.0002 seconds
Process exited with code 0
Original token count: 18
Output:
........

  Tests:    42 passed (337 assertions)
  Duration: 13.67s


message: The API group is green with tax settings: 42 tests, 337 assertions, and route-list shows 25 admin API routes. I’m updating progress, then running the full suite.


text: The API group is green with tax settings: 42 tests, 337 assertions, and route-list shows 25 admin API routes. I’m updating progress, then running the full suite.


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
-| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, search status/reindex endpoints, analytics summary, 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, search status/reindex endpoints, analytics summary, 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. |
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminAnalyticsSummaryApiTest.php` passed after the admin analytics summary API changes: 3 tests, 26 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin analytics summary API changes: 39 tests, 311 assertions.
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 23 admin API routes, including analytics summary.
 - 2026-05-04: `php artisan test --compact` passed after the admin analytics summary API changes: 226 tests, 1268 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 array/boolean validation, update-or-create patterns, JSON API assertions, and Pest 4 docs before the admin tax settings API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/TaxSettingsController --no-interaction`, `php artisan make:resource Admin/V1/TaxSettingsResource --no-interaction`, and `php artisan make:test Api/AdminTaxSettingsApiTest --pest --no-interaction` created the tax settings API controller/resource and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin tax settings API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminTaxSettingsApiTest.php` passed after the admin tax settings API changes: 3 tests, 26 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin tax settings API changes: 42 tests, 337 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 25 admin API routes, including tax settings read/update.
@@
-- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, content page CRUD, search status/reindex, analytics summary, and order fulfillment/refund mutations.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, content page CRUD, search status/reindex, analytics summary, tax settings read/update, and order fulfillment/refund mutations.
@@
 - Admin settings are split across `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, and `/admin/settings/notifications`; domains are managed on the general settings page because the current route surface does not need a separate domains route.
+- The tax settings API accepts the spec-facing `default_tax_rate`/`tax_rates` payload shape and normalizes it to the existing internal `default_rate_bps`/`rates` config consumed by the calculator and admin UI.
 - 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 settings, themes, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST surfaces for shipping/general settings, themes, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
@@
-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/search/analytics/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/search/analytics/tax settings/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
*** End Patch


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

unified_diff:
@@ -29,3 +29,3 @@
 | Database schema | `specs/01-DATABASE-SCHEMA.md` | partial | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, and Phase 7 search/analytics/apps/webhooks tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, and webhook_deliveries. |
-| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, search status/reindex endpoints, analytics summary, 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, search status/reindex endpoints, analytics summary, 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. |
 | 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. |
@@ -368,2 +368,8 @@
 - 2026-05-04: `php artisan test --compact` passed after the admin analytics summary API changes: 226 tests, 1268 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 array/boolean validation, update-or-create patterns, JSON API assertions, and Pest 4 docs before the admin tax settings API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/TaxSettingsController --no-interaction`, `php artisan make:resource Admin/V1/TaxSettingsResource --no-interaction`, and `php artisan make:test Api/AdminTaxSettingsApiTest --pest --no-interaction` created the tax settings API controller/resource and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin tax settings API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminTaxSettingsApiTest.php` passed after the admin tax settings API changes: 3 tests, 26 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin tax settings API changes: 42 tests, 337 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 25 admin API routes, including tax settings read/update.

@@ -401,3 +407,3 @@
 - Storefront order lookup uses a deterministic HMAC access token derived from store/order identity so confirmation links can be token-gated without adding a new runtime column.
-- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, content page CRUD, search status/reindex, analytics summary, and order fulfillment/refund mutations.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, content page CRUD, search status/reindex, analytics summary, tax settings read/update, and order fulfillment/refund mutations.
 - Admin dashboard sales and top-product metrics are derived from paid or partially refunded orders; the dedicated analytics page now reads analytics event and daily aggregate tables.
@@ -407,2 +413,3 @@
 - Admin settings are split across `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, and `/admin/settings/notifications`; domains are managed on the general settings page because the current route surface does not need a separate domains route.
+- The tax settings API accepts the spec-facing `default_tax_rate`/`tax_rates` payload shape and normalizes it to the existing internal `default_rate_bps`/`rates` config consumed by the calculator and admin UI.
 - 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.
@@ -433,3 +440,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 settings, themes, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST surfaces for shipping/general settings, themes, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
 - Full automated browser suites from Spec 08 are still incomplete beyond the initial Pest browser smoke coverage for storefront, admin, and mobile rendering.
@@ -439,2 +446,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/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/search/analytics/tax settings/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.


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


message: Progress now includes tax settings and the config translation decision. I’m running the full suite before committing.


text: Progress now includes tax settings and the config translation decision. I’m running the full suite before committing.


output:
Chunk ID: a9f5a3
Wall time: 1.0007 seconds
Process running with session ID 15229
Original token count: 1
Output:

  .


output:
Chunk ID: b29578
Wall time: 5.0022 seconds
Process running with session ID 15229
Original token count: 8
Output:
...............................


output:
Chunk ID: 40cbc3
Wall time: 5.0021 seconds
Process running with session ID 15229
Original token count: 8
Output:
.............................


output:
Chunk ID: e3fdbd
Wall time: 5.0024 seconds
Process running with session ID 15229
Original token count: 16
Output:
...............
  .............................................


output:
Chunk ID: 0a9b6f
Wall time: 5.0013 seconds
Process running with session ID 15229
Original token count: 23
Output:
...............................
  ..........................................................


output:
Chunk ID: 998381
Wall time: 4.4422 seconds
Process exited with code 0
Original token count: 22
Output:
..................
  .

  Tests:    229 passed (1294 assertions)
  Duration: 56.72s


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

cmd: php artisan test --compact


message: Full suite is green after tax settings: 229 tests, 1294 assertions. I’ll record that and commit this slice.


text: Full suite is green after tax settings: 229 tests, 1294 assertions. I’ll record that and commit this slice.


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminTaxSettingsApiTest.php` passed after the admin tax settings API changes: 3 tests, 26 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin tax settings API changes: 42 tests, 337 assertions.
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 25 admin API routes, including tax settings read/update.
+- 2026-05-04: `php artisan test --compact` passed after the admin tax settings API changes: 229 tests, 1294 assertions.

 ## Decisions
*** End Patch


unified_diff:
@@ -374,2 +374,3 @@
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 25 admin API routes, including tax settings read/update.
+- 2026-05-04: `php artisan test --compact` passed after the admin tax settings API changes: 229 tests, 1294 assertions.


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


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

cmd: git status --short

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


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


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

cmd: git diff --check


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


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

cmd: git diff --stat

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


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


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

cmd: git clean -f .playwright-mcp

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


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


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


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


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

TaxSettingsController.php

type: read

aggregated_output:
<?php

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

use App\Enums\TaxMode;
use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\TaxSettingsResource;
use App\Models\Store;
use App\Models\TaxSettings;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

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

        return TaxSettingsResource::make($this->settings($store));
    }

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

        $validated = $request->validate([
            'mode' => ['required', Rule::in(array_map(fn (TaxMode $mode): string => $mode->value, TaxMode::cases()))],
            'provider' => ['required_if:mode,provider', Rule::in(['none', 'stripe_tax'])],
            'prices_include_tax' => ['required', 'boolean'],
            'config_json' => ['required', 'array'],
            'config_json.default_tax_rate' => ['nullable', 'integer', 'min:0', 'max:10000'],
            'config_json.default_rate_bps' => ['nullable', 'integer', 'min:0', 'max:10000'],
            'config_json.tax_rates' => ['nullable', 'array'],
            'config_json.tax_rates.*.country_code' => ['required_with:config_json.tax_rates', 'string', 'size:2'],
            'config_json.tax_rates.*.rate' => ['required_with:config_json.tax_rates', 'integer', 'min:0', 'max:10000'],
            'config_json.tax_rates.*.name' => ['required_with:config_json.tax_rates', 'string', 'max:50'],
            'config_json.tax_rates.*.shipping_taxed' => ['nullable', 'boolean'],
            'config_json.rates' => ['nullable', 'array'],
            'config_json.rates.*.country' => ['required_with:config_json.rates', 'string', 'size:2'],
            'config_json.rates.*.rate_bps' => ['required_with:config_json.rates', 'integer', 'min:0', 'max:10000'],
            'config_json.rates.*.name' => ['required_with:config_json.rates', 'string', 'max:50'],
        ]);

        $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(
            ['store_id' => $store->getKey()],
            [
                'mode' => TaxMode::from($validated['mode']),
                'provider' => $validated['mode'] === TaxMode::Provider->value ? $validated['provider'] : 'none',
                'prices_include_tax' => (bool) $validated['prices_include_tax'],
                'config_json' => $this->normalizeConfig($request->input('config_json', [])),
            ],
        );

        return TaxSettingsResource::make($settings->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): TaxSettings
    {
        return TaxSettings::withoutGlobalScopes()->firstOrCreate(
            ['store_id' => $store->getKey()],
            [
                'mode' => TaxMode::Manual,
                'provider' => 'none',
                'prices_include_tax' => false,
                'config_json' => [
                    'name' => 'Tax',
                    'default_rate_bps' => 0,
                    'shipping_taxable' => true,
                    'rates' => [],
                ],
            ],
        );
    }

    /**
     * @param  array<string, mixed>  $config
     * @return array<string, mixed>
     */
    private function normalizeConfig(array $config): array
    {
        $rates = collect($config['tax_rates'] ?? $config['rates'] ?? [])
            ->map(fn (array $rate): array => [
                'country' => strtoupper((string) ($rate['country_code'] ?? $rate['country'])),
                'rate_bps' => (int) ($rate['rate'] ?? $rate['rate_bps']),
                'name' => (string) $rate['name'],
                'shipping_taxed' => (bool) ($rate['shipping_taxed'] ?? true),
            ])
            ->values()
            ->all();

        return [
            ...$config,
            'default_rate_bps' => (int) ($config['default_tax_rate'] ?? $config['default_rate_bps'] ?? 0),
            'shipping_taxable' => (bool) ($config['shipping_taxable'] ?? true),
            'rates' => $rates,
        ];
    }
}


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

aggregated_output:
<?php

namespace App\Http\Resources\Admin\V1;

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

class TaxSettingsResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'store_id' => $this->store_id,
            'mode' => $this->mode?->value,
            'provider' => $this->provider,
            'prices_include_tax' => $this->prices_include_tax,
            'config_json' => $this->publicConfig(),
            'updated_at' => null,
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function publicConfig(): array
    {
        $config = $this->config_json ?? [];

        if ($this->mode?->value === 'provider') {
            return Arr::except($config, ['provider_api_key']);
        }

        return [
            ...Arr::except($config, ['default_rate_bps', 'rates', 'provider_api_key']),
            'default_tax_rate' => (int) data_get($config, 'default_tax_rate', data_get($config, 'default_rate_bps', 0)),
            'tax_rates' => collect(data_get($config, 'tax_rates', data_get($config, 'rates', [])))
                ->map(fn (array $rate): array => [
                    'country_code' => strtoupper((string) data_get($rate, 'country_code', data_get($rate, 'country'))),
                    'rate' => (int) data_get($rate, 'rate', data_get($rate, 'rate_bps', 0)),
                    'name' => (string) data_get($rate, 'name', 'Tax'),
                    'shipping_taxed' => (bool) data_get($rate, 'shipping_taxed', data_get($config, 'shipping_taxable', true)),
                ])
                ->values()
                ->all(),
        ];
    }
}


aggregated_output:
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php
?? app/Http/Resources/Admin/V1/TaxSettingsResource.php
?? tests/Feature/Api/AdminTaxSettingsApiTest.php


output:
Chunk ID: 46e719
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 52
Output:
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php
?? app/Http/Resources/Admin/V1/TaxSettingsResource.php
?? tests/Feature/Api/AdminTaxSettingsApiTest.php


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

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

use App\Enums\TaxMode;
use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\TaxSettingsResource;
use App\Models\Store;
use App\Models\TaxSettings;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

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

        return TaxSettingsResource::make($this->settings($store));
    }

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

        $validated = $request->validate([
            'mode' => ['required', Rule::in(array_map(fn (TaxMode $mode): string => $mode->value, TaxMode::cases()))],
            'provider' => ['required_if:mode,provider', Rule::in(['none', 'stripe_tax'])],
            'prices_include_tax' => ['required', 'boolean'],
            'config_json' => ['required', 'array'],
            'config_json.default_tax_rate' => ['nullable', 'integer', 'min:0', 'max:10000'],
            'config_json.default_rate_bps' => ['nullable', 'integer', 'min:0', 'max:10000'],
            'config_json.tax_rates' => ['nullable', 'array'],
            'config_json.tax_rates.*.country_code' => ['required_with:config_json.tax_rates', 'string', 'size:2'],
            'config_json.tax_rates.*.rate' => ['required_with:config_json.tax_rates', 'integer', 'min:0', 'max:10000'],
            'config_json.tax_rates.*.name' => ['required_with:config_json.tax_rates', 'string', 'max:50'],
            'config_json.tax_rates.*.shipping_taxed' => ['nullable', 'boolean'],
            'config_json.rates' => ['nullable', 'array'],
            'config_json.rates.*.country' => ['required_with:config_json.rates', 'string', 'size:2'],
            'config_json.rates.*.rate_bps' => ['required_with:config_json.rates', 'integer', 'min:0', 'max:10000'],
            'config_json.rates.*.name' => ['required_with:config_json.rates', 'string', 'max:50'],
        ]);

        $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(
            ['store_id' => $store->getKey()],
            [
                'mode' => TaxMode::from($validated['mode']),
                'provider' => $validated['mode'] === TaxMode::Provider->value ? $validated['provider'] : 'none',
                'prices_include_tax' => (bool) $validated['prices_include_tax'],
                'config_json' => $this->normalizeConfig($request->input('config_json', [])),
            ],
        );

        return TaxSettingsResource::make($settings->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): TaxSettings
    {
        return TaxSettings::withoutGlobalScopes()->firstOrCreate(
            ['store_id' => $store->getKey()],
            [
                'mode' => TaxMode::Manual,
                'provider' => 'none',
                'prices_include_tax' => false,
                'config_json' => [
                    'name' => 'Tax',
                    'default_rate_bps' => 0,
                    'shipping_taxable' => true,
                    'rates' => [],
                ],
            ],
        );
    }

    /**
     * @param  array<string, mixed>  $config
     * @return array<string, mixed>
     */
    private function normalizeConfig(array $config): array
    {
        $rates = collect($config['tax_rates'] ?? $config['rates'] ?? [])
            ->map(fn (array $rate): array => [
                'country' => strtoupper((string) ($rate['country_code'] ?? $rate['country'])),
                'rate_bps' => (int) ($rate['rate'] ?? $rate['rate_bps']),
                'name' => (string) $rate['name'],
                'shipping_taxed' => (bool) ($rate['shipping_taxed'] ?? true),
            ])
            ->values()
            ->all();

        return [
            ...$config,
            'default_rate_bps' => (int) ($config['default_tax_rate'] ?? $config['default_rate_bps'] ?? 0),
            'shipping_taxable' => (bool) ($config['shipping_taxable'] ?? true),
            'rates' => $rates,
        ];
    }
}


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

namespace App\Http\Resources\Admin\V1;

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

class TaxSettingsResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'store_id' => $this->store_id,
            'mode' => $this->mode?->value,
            'provider' => $this->provider,
            'prices_include_tax' => $this->prices_include_tax,
            'config_json' => $this->publicConfig(),
            'updated_at' => null,
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function publicConfig(): array
    {
        $config = $this->config_json ?? [];

        if ($this->mode?->value === 'provider') {
            return Arr::except($config, ['provider_api_key']);
        }

        return [
            ...Arr::except($config, ['default_rate_bps', 'rates', 'provider_api_key']),
            'default_tax_rate' => (int) data_get($config, 'default_tax_rate', data_get($config, 'default_rate_bps', 0)),
            'tax_rates' => collect(data_get($config, 'tax_rates', data_get($config, 'rates', [])))
                ->map(fn (array $rate): array => [
                    'country_code' => strtoupper((string) data_get($rate, 'country_code', data_get($rate, 'country'))),
                    'rate' => (int) data_get($rate, 'rate', data_get($rate, 'rate_bps', 0)),
                    'name' => (string) data_get($rate, 'name', 'Tax'),
                    'shipping_taxed' => (bool) data_get($rate, 'shipping_taxed', data_get($config, 'shipping_taxable', true)),
                ])
                ->values()
                ->all(),
        ];
    }
}