sed -n '260,520p' app/Livewire/Admin/Settings/Shipping.php

        };

        return Money::format($amount, $this->scopedStore()->default_currency);
    }

    public function render(): mixed
    {
        return view('livewire.admin.settings.shipping', [
            'zones' => $this->zones(),
        ])->layout('layouts.app', [
            'title' => __('Shipping'),
        ]);
    }

    private function store(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function scopedStore(): Store
    {
        return Store::query()->whereKey($this->storeId)->firstOrFail();
    }

    private function zone(int $zoneId): ShippingZone
    {
        return ShippingZone::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->whereKey($zoneId)
            ->firstOrFail();
    }

    private function rate(int $rateId): ShippingRate
    {
        return ShippingRate::withoutGlobalScopes()
            ->whereKey($rateId)
            ->whereHas('zone', function ($query): void {
                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
            })
            ->firstOrFail();
    }

    /**
     * @return list<string>
     */
    private function countryCodes(string $countries): array
    {
        return collect(explode(',', $countries))
            ->map(fn (string $country): string => strtoupper(trim($country)))
            ->filter(fn (string $country): bool => preg_match('/^[A-Z]{2}$/', $country) === 1)
            ->unique()
            ->values()
            ->all();
    }

    /**
     * @return array<string, mixed>
     */
    private function rateConfig(): array
    {
        $amount = Money::fromDecimalString($this->rateAmount);

        return match ($this->rateType) {
            ShippingRateType::Weight->value => [
                'ranges' => [[
                    'min_g' => (int) ($this->minimumWeight ?: 0),
                    'max_g' => $this->maximumWeight !== '' ? (int) $this->maximumWeight : null,
                    'amount' => $amount,
                ]],
            ],
            ShippingRateType::Price->value => [
                'ranges' => [[
                    'min_amount' => Money::fromDecimalString($this->minimumOrderAmount),
                    'max_amount' => $this->maximumOrderAmount !== '' ? Money::fromDecimalString($this->maximumOrderAmount) : null,
                    'amount' => $amount,
                ]],
            ],
            ShippingRateType::Carrier->value => ['amount' => $amount],
            default => ['amount' => $amount],
        };
    }

    private function fillRateConfig(ShippingRate $rate): void
    {
        $amount = match ($rate->type) {
            ShippingRateType::Flat, ShippingRateType::Carrier => (int) data_get($rate->config_json, 'amount', 0),
            default => (int) data_get($rate->config_json, 'ranges.0.amount', 0),
        };

        $this->rateAmount = number_format($amount / 100, 2, '.', '');
        $this->minimumWeight = (string) data_get($rate->config_json, 'ranges.0.min_g', 0);
        $this->maximumWeight = data_get($rate->config_json, 'ranges.0.max_g') !== null
            ? (string) data_get($rate->config_json, 'ranges.0.max_g')
            : '';
        $this->minimumOrderAmount = number_format(((int) data_get($rate->config_json, 'ranges.0.min_amount', 0)) / 100, 2, '.', '');
        $this->maximumOrderAmount = data_get($rate->config_json, 'ranges.0.max_amount') !== null
            ? number_format(((int) data_get($rate->config_json, 'ranges.0.max_amount')) / 100, 2, '.', '')
            : '';
    }

    private function resetZoneForm(): void
    {
        $this->editingZoneId = null;
        $this->zoneName = '';
        $this->zoneCountries = '';
    }

    private function resetRateForm(): void
    {
        $this->editingRateId = null;
        $this->rateZoneId = null;
        $this->rateName = '';
        $this->rateType = 'flat';
        $this->rateAmount = '0.00';
        $this->minimumWeight = '0';
        $this->maximumWeight = '';
        $this->minimumOrderAmount = '0.00';
        $this->maximumOrderAmount = '';
        $this->rateActive = true;
    }
}


Output:
Chunk ID: 9cb3ea
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1052
Output:
        };

        return Money::format($amount, $this->scopedStore()->default_currency);
    }

    public function render(): mixed
    {
        return view('livewire.admin.settings.shipping', [
            'zones' => $this->zones(),
        ])->layout('layouts.app', [
            'title' => __('Shipping'),
        ]);
    }

    private function store(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function scopedStore(): Store
    {
        return Store::query()->whereKey($this->storeId)->firstOrFail();
    }

    private function zone(int $zoneId): ShippingZone
    {
        return ShippingZone::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->whereKey($zoneId)
            ->firstOrFail();
    }

    private function rate(int $rateId): ShippingRate
    {
        return ShippingRate::withoutGlobalScopes()
            ->whereKey($rateId)
            ->whereHas('zone', function ($query): void {
                $query->withoutGlobalScopes()->where('store_id', $this->storeId);
            })
            ->firstOrFail();
    }

    /**
     * @return list<string>
     */
    private function countryCodes(string $countries): array
    {
        return collect(explode(',', $countries))
            ->map(fn (string $country): string => strtoupper(trim($country)))
            ->filter(fn (string $country): bool => preg_match('/^[A-Z]{2}$/', $country) === 1)
            ->unique()
            ->values()
            ->all();
    }

    /**
     * @return array<string, mixed>
     */
    private function rateConfig(): array
    {
        $amount = Money::fromDecimalString($this->rateAmount);

        return match ($this->rateType) {
            ShippingRateType::Weight->value => [
                'ranges' => [[
                    'min_g' => (int) ($this->minimumWeight ?: 0),
                    'max_g' => $this->maximumWeight !== '' ? (int) $this->maximumWeight : null,
                    'amount' => $amount,
                ]],
            ],
            ShippingRateType::Price->value => [
                'ranges' => [[
                    'min_amount' => Money::fromDecimalString($this->minimumOrderAmount),
                    'max_amount' => $this->maximumOrderAmount !== '' ? Money::fromDecimalString($this->maximumOrderAmount) : null,
                    'amount' => $amount,
                ]],
            ],
            ShippingRateType::Carrier->value => ['amount' => $amount],
            default => ['amount' => $amount],
        };
    }

    private function fillRateConfig(ShippingRate $rate): void
    {
        $amount = match ($rate->type) {
            ShippingRateType::Flat, ShippingRateType::Carrier => (int) data_get($rate->config_json, 'amount', 0),
            default => (int) data_get($rate->config_json, 'ranges.0.amount', 0),
        };

        $this->rateAmount = number_format($amount / 100, 2, '.', '');
        $this->minimumWeight = (string) data_get($rate->config_json, 'ranges.0.min_g', 0);
        $this->maximumWeight = data_get($rate->config_json, 'ranges.0.max_g') !== null
            ? (string) data_get($rate->config_json, 'ranges.0.max_g')
            : '';
        $this->minimumOrderAmount = number_format(((int) data_get($rate->config_json, 'ranges.0.min_amount', 0)) / 100, 2, '.', '');
        $this->maximumOrderAmount = data_get($rate->config_json, 'ranges.0.max_amount') !== null
            ? number_format(((int) data_get($rate->config_json, 'ranges.0.max_amount')) / 100, 2, '.', '')
            : '';
    }

    private function resetZoneForm(): void
    {
        $this->editingZoneId = null;
        $this->zoneName = '';
        $this->zoneCountries = '';
    }

    private function resetRateForm(): void
    {
        $this->editingRateId = null;
        $this->rateZoneId = null;
        $this->rateName = '';
        $this->rateType = 'flat';
        $this->rateAmount = '0.00';
        $this->minimumWeight = '0';
        $this->maximumWeight = '';
        $this->minimumOrderAmount = '0.00';
        $this->maximumOrderAmount = '';
        $this->rateActive = true;
    }
}


rg -n "ShippingZone|ShippingRate|shipping" database/seeders


rg -n "ShippingZone|ShippingRate|shipping" database/seeders

rg -n 'ShippingZone|ShippingRate|shipping' database/seeders

Question: ShippingZone|ShippingRate|shipping

database/seeders/DatabaseSeeder.php:32:            ShippingZoneSeeder::class,
database/seeders/DatabaseSeeder.php:33:            ShippingRateSeeder::class,
database/seeders/TaxSettingsSeeder.php:26:                        'shipping_taxable' => true,
database/seeders/ShippingZoneSeeder.php:5:use App\Models\ShippingZone;
database/seeders/ShippingZoneSeeder.php:9:class ShippingZoneSeeder extends Seeder
database/seeders/ShippingZoneSeeder.php:17:            ShippingZone::withoutGlobalScopes()->updateOrCreate(
database/seeders/PageSeeder.php:51:                'handle' => 'shipping',
database/seeders/StoreSettingsSeeder.php:23:                            'text' => 'Free shipping on orders over 75.00 EUR',
database/seeders/StoreSettingsSeeder.php:42:                            'shipping_confirmation_enabled' => true,
database/seeders/DiscountSeeder.php:47:            ['code' => 'FREESHIP', 'value_type' => 'free_shipping', 'value_amount' => 0, 'rules_json' => ['customer_eligibility' => 'all']],
database/seeders/ProductSeeder.php:132:                'requires_shipping' => $variantData['requires_shipping'] ?? true,
database/seeders/ProductSeeder.php:291:            $this->product('Gift Card', 'gift-card', 'Acme Fashion', 'Gift Cards', ['popular'], [], '
Digital gift card delivered via email. The perfect gift when you are not sure what to choose.
', [['Amount' => ['25 EUR', '50 EUR', '100 EUR']]], ['price_amount' => 2500, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999], 'ACME-GIFT', variants: [
database/seeders/ProductSeeder.php:292:                ['options' => ['Amount' => '25 EUR'], 'sku' => 'ACME-GIFT-25', 'price_amount' => 2500, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999],
database/seeders/ProductSeeder.php:293:                ['options' => ['Amount' => '50 EUR'], 'sku' => 'ACME-GIFT-50', 'price_amount' => 5000, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999],
database/seeders/ProductSeeder.php:294:                ['options' => ['Amount' => '100 EUR'], 'sku' => 'ACME-GIFT-100', 'price_amount' => 10000, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999],
database/seeders/ProductSeeder.php:352:                'requires_shipping' => true,
database/seeders/ShippingRateSeeder.php:5:use App\Models\ShippingRate;
database/seeders/ShippingRateSeeder.php:6:use App\Models\ShippingZone;
database/seeders/ShippingRateSeeder.php:9:class ShippingRateSeeder extends Seeder
database/seeders/ShippingRateSeeder.php:16:        ShippingZone::withoutGlobalScopes()->get()->each(function (ShippingZone $zone): void {
database/seeders/ShippingRateSeeder.php:18:                ShippingRate::withoutGlobalScopes()->updateOrCreate(


Output:
Chunk ID: e52458
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 690
Output:
database/seeders/DatabaseSeeder.php:32:            ShippingZoneSeeder::class,
database/seeders/DatabaseSeeder.php:33:            ShippingRateSeeder::class,
database/seeders/TaxSettingsSeeder.php:26:                        'shipping_taxable' => true,
database/seeders/ShippingZoneSeeder.php:5:use App\Models\ShippingZone;
database/seeders/ShippingZoneSeeder.php:9:class ShippingZoneSeeder extends Seeder
database/seeders/ShippingZoneSeeder.php:17:            ShippingZone::withoutGlobalScopes()->updateOrCreate(
database/seeders/PageSeeder.php:51:                'handle' => 'shipping',
database/seeders/StoreSettingsSeeder.php:23:                            'text' => 'Free shipping on orders over 75.00 EUR',
database/seeders/StoreSettingsSeeder.php:42:                            'shipping_confirmation_enabled' => true,
database/seeders/DiscountSeeder.php:47:            ['code' => 'FREESHIP', 'value_type' => 'free_shipping', 'value_amount' => 0, 'rules_json' => ['customer_eligibility' => 'all']],
database/seeders/ProductSeeder.php:132:                'requires_shipping' => $variantData['requires_shipping'] ?? true,
database/seeders/ProductSeeder.php:291:            $this->product('Gift Card', 'gift-card', 'Acme Fashion', 'Gift Cards', ['popular'], [], '
Digital gift card delivered via email. The perfect gift when you are not sure what to choose.
', [['Amount' => ['25 EUR', '50 EUR', '100 EUR']]], ['price_amount' => 2500, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999], 'ACME-GIFT', variants: [
database/seeders/ProductSeeder.php:292:                ['options' => ['Amount' => '25 EUR'], 'sku' => 'ACME-GIFT-25', 'price_amount' => 2500, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999],
database/seeders/ProductSeeder.php:293:                ['options' => ['Amount' => '50 EUR'], 'sku' => 'ACME-GIFT-50', 'price_amount' => 5000, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999],
database/seeders/ProductSeeder.php:294:                ['options' => ['Amount' => '100 EUR'], 'sku' => 'ACME-GIFT-100', 'price_amount' => 10000, 'weight_g' => 0, 'requires_shipping' => false, 'quantity_on_hand' => 9999],
database/seeders/ProductSeeder.php:352:                'requires_shipping' => true,
database/seeders/ShippingRateSeeder.php:5:use App\Models\ShippingRate;
database/seeders/ShippingRateSeeder.php:6:use App\Models\ShippingZone;
database/seeders/ShippingRateSeeder.php:9:class ShippingRateSeeder extends Seeder
database/seeders/ShippingRateSeeder.php:16:        ShippingZone::withoutGlobalScopes()->get()->each(function (ShippingZone $zone): void {
database/seeders/ShippingRateSeeder.php:18:                ShippingRate::withoutGlobalScopes()->updateOrCreate(


sed -n '1,160p' database/seeders/ShippingZoneSeeder.php && sed -n '1,180p' database/seeders/ShippingRateSeeder.php


sed -n '1,160p' database/seeders/ShippingZoneSeeder.php && sed -n '1,180p' database/seeders/ShippingRateSeeder.php

ShippingRateSeeder.php

<?php

namespace Database\Seeders;

use App\Models\ShippingZone;
use App\Models\Store;
use Illuminate\Database\Seeder;

class ShippingZoneSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Store::query()->get()->each(function (Store $store): void {
            ShippingZone::withoutGlobalScopes()->updateOrCreate(
                [
                    'store_id' => $store->getKey(),
                    'name' => 'DACH',
                ],
                [
                    'countries_json' => ['DE', 'AT', 'CH'],
                    'regions_json' => [],
                ],
            );
        });
    }
}
<?php

namespace Database\Seeders;

use App\Models\ShippingRate;
use App\Models\ShippingZone;
use Illuminate\Database\Seeder;

class ShippingRateSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        ShippingZone::withoutGlobalScopes()->get()->each(function (ShippingZone $zone): void {
            foreach ($this->rates() as $rate) {
                ShippingRate::withoutGlobalScopes()->updateOrCreate(
                    [
                        'zone_id' => $zone->getKey(),
                        'name' => $rate['name'],
                    ],
                    [
                        'type' => $rate['type'],
                        'config_json' => $rate['config_json'],
                        'is_active' => true,
                    ],
                );
            }
        });
    }

    /**
     * @return array<int, array{name: string, type: string, config_json: array<string, mixed>}>
     */
    private function rates(): array
    {
        return [
            ['name' => 'Standard Shipping', 'type' => 'flat', 'config_json' => ['amount' => 799]],
            ['name' => 'Express Shipping', 'type' => 'flat', 'config_json' => ['amount' => 1499]],
            [
                'name' => 'Free Shipping Over 75',
                'type' => 'price',
                'config_json' => [
                    'ranges' => [
                        ['min_amount' => 0, 'max_amount' => 7499, 'amount' => 799],
                        ['min_amount' => 7500, 'amount' => 0],
                    ],
                ],
            ],
        ];
    }
}


Output:
Chunk ID: 5ec4a0
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 578
Output:
<?php

namespace Database\Seeders;

use App\Models\ShippingZone;
use App\Models\Store;
use Illuminate\Database\Seeder;

class ShippingZoneSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Store::query()->get()->each(function (Store $store): void {
            ShippingZone::withoutGlobalScopes()->updateOrCreate(
                [
                    'store_id' => $store->getKey(),
                    'name' => 'DACH',
                ],
                [
                    'countries_json' => ['DE', 'AT', 'CH'],
                    'regions_json' => [],
                ],
            );
        });
    }
}
<?php

namespace Database\Seeders;

use App\Models\ShippingRate;
use App\Models\ShippingZone;
use Illuminate\Database\Seeder;

class ShippingRateSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        ShippingZone::withoutGlobalScopes()->get()->each(function (ShippingZone $zone): void {
            foreach ($this->rates() as $rate) {
                ShippingRate::withoutGlobalScopes()->updateOrCreate(
                    [
                        'zone_id' => $zone->getKey(),
                        'name' => $rate['name'],
                    ],
                    [
                        'type' => $rate['type'],
                        'config_json' => $rate['config_json'],
                        'is_active' => true,
                    ],
                );
            }
        });
    }

    /**
     * @return array<int, array{name: string, type: string, config_json: array<string, mixed>}>
     */
    private function rates(): array
    {
        return [
            ['name' => 'Standard Shipping', 'type' => 'flat', 'config_json' => ['amount' => 799]],
            ['name' => 'Express Shipping', 'type' => 'flat', 'config_json' => ['amount' => 1499]],
            [
                'name' => 'Free Shipping Over 75',
                'type' => 'price',
                'config_json' => [
                    'ranges' => [
                        ['min_amount' => 0, 'max_amount' => 7499, 'amount' => 799],
                        ['min_amount' => 7500, 'amount' => 0],
                    ],
                ],
            ],
        ];
    }
}


For shipping, I’ll keep the API shape close to the spec (`price_amount`, `tiers`) while normalizing into the existing calculator shape (`amount`, `ranges`). I’ll also enforce store-scoped zone ownership and reject overlapping country coverage.


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


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


php artisan make:resource Admin/V1/ShippingZoneResource --no-interaction


php artisan make:resource Admin/V1/ShippingRateResource --no-interaction


php artisan make:test Api/AdminShippingSettingsApiTest --pest --no-interaction


Output:
Chunk ID: d0fef8
Wall time: 0.1692 seconds
Process exited with code 0
Original token count: 28
Output:

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


Output:
Chunk ID: d9cf84
Wall time: 0.1672 seconds
Process exited with code 0
Original token count: 28
Output:

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


Output:
Chunk ID: 62e7f1
Wall time: 0.1511 seconds
Process exited with code 0
Original token count: 25
Output:

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


Output:
Chunk ID: e1c58f
Wall time: 0.1484 seconds
Process exited with code 0
Original token count: 25
Output:

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


Output:
Chunk ID: 4be7de
Wall time: 0.1481 seconds
Process exited with code 0
Original token count: 24
Output:

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


php artisan make:resource Admin/V1/ShippingZoneResource --no-interaction

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


php artisan make:resource Admin/V1/ShippingRateResource --no-interaction

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


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

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


php artisan make:test Api/AdminShippingSettingsApiTest --pest --no-interaction

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


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

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


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

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

 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\ShippingZoneResource;
+use App\Models\ShippingZone;
+use App\Models\Store;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Validation\ValidationException;

 class ShippingZoneController extends Controller
 {
-    //
+    public function index(Request $request, Store $store): AnonymousResourceCollection
+    {
+        $this->authorizeStore($request, $store);
+
+        $zones = ShippingZone::withoutGlobalScopes()
+            ->with(['rates' => fn ($query) => $query->withoutGlobalScopes()->orderBy('id'), 'store'])
+            ->where('store_id', $store->getKey())
+            ->orderBy('id')
+            ->get();
+
+        return ShippingZoneResource::collection($zones);
+    }
+
+    public function store(Request $request, Store $store): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $this->validatePayload($request, $store);
+
+        $zone = ShippingZone::withoutGlobalScopes()->create([
+            'store_id' => $store->getKey(),
+            ...$this->attributes($validated),
+        ]);
+
+        return ShippingZoneResource::make($this->loadZone($zone))
+            ->response()
+            ->setStatusCode(201);
+    }
+
+    public function update(Request $request, Store $store, ShippingZone $shippingZone): ShippingZoneResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessZoneBelongsToStore($shippingZone, $store);
+
+        $validated = $this->validatePayload($request, $store, $shippingZone);
+
+        $shippingZone->update($this->attributes($validated));
+
+        return ShippingZoneResource::make($this->loadZone($shippingZone->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 abortUnlessZoneBelongsToStore(ShippingZone $zone, Store $store): void
+    {
+        abort_unless((int) $zone->store_id === $store->getKey(), 404);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function validatePayload(Request $request, Store $store, ?ShippingZone $zone = null): array
+    {
+        $validated = $request->validate([
+            'name' => ['required', 'string', 'max:255'],
+            'countries_json' => ['required', 'array', 'min:1'],
+            'countries_json.*' => ['required', 'string', 'size:2'],
+            'regions_json' => ['sometimes', 'array'],
+            'regions_json.*' => ['string', 'max:20'],
+        ]);
+
+        $countries = $this->countryCodes($validated['countries_json']);
+
+        if ($countries === []) {
+            throw ValidationException::withMessages([
+                'countries_json' => __('Enter at least one ISO country code.'),
+            ]);
+        }
+
+        $overlap = ShippingZone::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->when($zone instanceof ShippingZone, fn (Builder $query) => $query->whereKeyNot($zone->getKey()))
+            ->get()
+            ->first(fn (ShippingZone $existing): bool => array_intersect($countries, $existing->countries_json ?? []) !== []);
+
+        if ($overlap instanceof ShippingZone) {
+            throw ValidationException::withMessages([
+                'countries_json' => __('One or more countries already belong to another shipping zone.'),
+            ]);
+        }
+
+        $validated['countries_json'] = $countries;
+        $validated['regions_json'] = $this->regionCodes($validated['regions_json'] ?? []);
+
+        return $validated;
+    }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<string, mixed>
+     */
+    private function attributes(array $validated): array
+    {
+        return [
+            'name' => $validated['name'],
+            'countries_json' => $validated['countries_json'],
+            'regions_json' => $validated['regions_json'],
+        ];
+    }
+
+    /**
+     * @param  list<string>  $countries
+     * @return list<string>
+     */
+    private function countryCodes(array $countries): array
+    {
+        return collect($countries)
+            ->map(fn (string $country): string => strtoupper(trim($country)))
+            ->filter(fn (string $country): bool => preg_match('/^[A-Z]{2}$/', $country) === 1)
+            ->unique()
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @param  list<string>  $regions
+     * @return list<string>
+     */
+    private function regionCodes(array $regions): array
+    {
+        return collect($regions)
+            ->map(fn (string $region): string => strtoupper(trim($region)))
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
+    }
+
+    private function loadZone(ShippingZone $zone): ShippingZone
+    {
+        return $zone->load([
+            'rates' => fn ($query) => $query->withoutGlobalScopes()->orderBy('id'),
+            'store',
+        ]);
+    }
 }
*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/ShippingRateController.php
@@
 <?php

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

+use App\Enums\ShippingRateType;
 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\ShippingRateResource;
+use App\Models\ShippingRate;
+use App\Models\ShippingZone;
+use App\Models\Store;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;

 class ShippingRateController extends Controller
 {
-    //
+    public function store(Request $request, Store $store, ShippingZone $shippingZone): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessZoneBelongsToStore($shippingZone, $store);
+
+        $validated = $request->validate([
+            'name' => ['required', 'string', 'max:255'],
+            'type' => ['required', Rule::in(array_map(fn (ShippingRateType $type): string => $type->value, ShippingRateType::cases()))],
+            'config_json' => ['required', 'array'],
+            'config_json.price_amount' => ['nullable', 'integer', 'min:0'],
+            'config_json.amount' => ['nullable', 'integer', 'min:0'],
+            'config_json.currency' => ['nullable', 'string', 'size:3'],
+            'config_json.tiers' => ['nullable', 'array'],
+            'config_json.tiers.*.min_weight_g' => ['nullable', 'integer', 'min:0'],
+            'config_json.tiers.*.max_weight_g' => ['nullable', 'integer', 'min:1'],
+            'config_json.tiers.*.min_order_amount' => ['nullable', 'integer', 'min:0'],
+            'config_json.tiers.*.max_order_amount' => ['nullable', 'integer', 'min:1'],
+            'config_json.tiers.*.price_amount' => ['nullable', 'integer', 'min:0'],
+            'config_json.ranges' => ['nullable', 'array'],
+            'is_active' => ['sometimes', 'boolean'],
+        ]);
+
+        $rate = ShippingRate::withoutGlobalScopes()->create([
+            'zone_id' => $shippingZone->getKey(),
+            'name' => $validated['name'],
+            'type' => ShippingRateType::from($validated['type']),
+            'config_json' => $this->normalizeConfig($validated['type'], $request->input('config_json', []), $store),
+            'is_active' => (bool) ($validated['is_active'] ?? true),
+        ]);
+
+        return ShippingRateResource::make($rate->load('zone.store'))
+            ->response()
+            ->setStatusCode(201);
+    }
+
+    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 abortUnlessZoneBelongsToStore(ShippingZone $zone, Store $store): void
+    {
+        abort_unless((int) $zone->store_id === $store->getKey(), 404);
+    }
+
+    /**
+     * @param  array<string, mixed>  $config
+     * @return array<string, mixed>
+     */
+    private function normalizeConfig(string $type, array $config, Store $store): array
+    {
+        if (in_array($type, [ShippingRateType::Flat->value, ShippingRateType::Carrier->value], true)) {
+            return [
+                'amount' => (int) ($config['price_amount'] ?? $config['amount'] ?? 0),
+                'currency' => strtoupper((string) ($config['currency'] ?? $store->default_currency)),
+            ];
+        }
+
+        if ($type === ShippingRateType::Weight->value) {
+            return [
+                'currency' => strtoupper((string) ($config['currency'] ?? $store->default_currency)),
+                'ranges' => collect($config['tiers'] ?? $config['ranges'] ?? [])
+                    ->map(fn (array $tier): array => [
+                        'min_g' => (int) ($tier['min_weight_g'] ?? $tier['min_g'] ?? 0),
+                        'max_g' => array_key_exists('max_weight_g', $tier) ? $tier['max_weight_g'] : ($tier['max_g'] ?? null),
+                        'amount' => (int) ($tier['price_amount'] ?? $tier['amount'] ?? 0),
+                    ])
+                    ->values()
+                    ->all(),
+            ];
+        }
+
+        return [
+            'currency' => strtoupper((string) ($config['currency'] ?? $store->default_currency)),
+            'ranges' => collect($config['tiers'] ?? $config['ranges'] ?? [])
+                ->map(fn (array $tier): array => [
+                    'min_amount' => (int) ($tier['min_order_amount'] ?? $tier['min_amount'] ?? 0),
+                    'max_amount' => array_key_exists('max_order_amount', $tier) ? $tier['max_order_amount'] : ($tier['max_amount'] ?? null),
+                    'amount' => (int) ($tier['price_amount'] ?? $tier['amount'] ?? 0),
+                ])
+                ->values()
+                ->all(),
+        ];
+    }
 }
*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/ShippingZoneResource.php
@@
 <?php

 namespace App\Http\Resources\Admin\V1;

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

 class ShippingZoneResource extends JsonResource
 {
@@
      */
     public function toArray(Request $request): array
     {
-        return parent::toArray($request);
+        return [
+            'id' => $this->id,
+            'store_id' => $this->store_id,
+            'name' => $this->name,
+            'countries_json' => $this->countries_json ?? [],
+            'regions_json' => $this->regions_json ?? [],
+            'rates' => ShippingRateResource::collection($this->whenLoaded('rates')),
+        ];
     }
 }
*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/ShippingRateResource.php
@@
 <?php

 namespace App\Http\Resources\Admin\V1;

+use App\Enums\ShippingRateType;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;

 class ShippingRateResource extends JsonResource
 {
@@
      */
     public function toArray(Request $request): array
     {
-        return parent::toArray($request);
+        return [
+            'id' => $this->id,
+            'zone_id' => $this->zone_id,
+            'name' => $this->name,
+            'type' => $this->type?->value,
+            'config_json' => $this->publicConfig(),
+            'is_active' => $this->is_active,
+        ];
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function publicConfig(): array
+    {
+        $config = $this->config_json ?? [];
+        $currency = strtoupper((string) data_get($config, 'currency', data_get($this->zone?->store, 'default_currency', 'EUR')));
+
+        if (in_array($this->type, [ShippingRateType::Flat, ShippingRateType::Carrier], true)) {
+            return [
+                'price_amount' => (int) data_get($config, 'price_amount', data_get($config, 'amount', 0)),
+                'currency' => $currency,
+            ];
+        }
+
+        if ($this->type === ShippingRateType::Weight) {
+            return [
+                'currency' => $currency,
+                'tiers' => collect(data_get($config, 'tiers', data_get($config, 'ranges', [])))
+                    ->map(fn (array $tier): array => [
+                        'min_weight_g' => (int) data_get($tier, 'min_weight_g', data_get($tier, 'min_g', 0)),
+                        'max_weight_g' => data_get($tier, 'max_weight_g', data_get($tier, 'max_g')),
+                        'price_amount' => (int) data_get($tier, 'price_amount', data_get($tier, 'amount', 0)),
+                    ])
+                    ->values()
+                    ->all(),
+            ];
+        }
+
+        return [
+            'currency' => $currency,
+            'tiers' => collect(data_get($config, 'tiers', data_get($config, 'ranges', [])))
+                ->map(fn (array $tier): array => [
+                    'min_order_amount' => (int) data_get($tier, 'min_order_amount', data_get($tier, 'min_amount', 0)),
+                    'max_order_amount' => data_get($tier, 'max_order_amount', data_get($tier, 'max_amount')),
+                    'price_amount' => (int) data_get($tier, 'price_amount', data_get($tier, 'amount', 0)),
+                ])
+                ->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\ShippingRateController as AdminShippingRateController;
+use App\Http\Controllers\Api\Admin\V1\ShippingZoneController as AdminShippingZoneController;
 use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
@@
         Route::middleware('admin.api:read-settings')->group(function (): void {
             Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
+            Route::get('shipping/zones', [AdminShippingZoneController::class, 'index'])->name('shipping.zones.index');
             Route::get('tax/settings', [AdminTaxSettingsController::class, 'show'])->name('tax.settings.show');
         });

         Route::middleware('admin.api:write-settings')->group(function (): void {
             Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
+            Route::post('shipping/zones', [AdminShippingZoneController::class, 'store'])->name('shipping.zones.store');
+            Route::put('shipping/zones/{shippingZone}', [AdminShippingZoneController::class, 'update'])->name('shipping.zones.update');
+            Route::post('shipping/zones/{shippingZone}/rates', [AdminShippingRateController::class, 'store'])->name('shipping.zones.rates.store');
             Route::put('tax/settings', [AdminTaxSettingsController::class, 'update'])->name('tax.settings.update');
         });
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminShippingSettingsApiTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\ShippingRateType;
+use App\Models\ShippingRate;
+use App\Models\ShippingZone;
+use App\Models\Store;
+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 adminShippingSettingsApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminShippingSettingsApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminShippingSettingsApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Shipping settings integration', $abilities);
+}
+
+test('admin shipping settings api lists creates updates zones and adds rates', function (): void {
+    $store = adminShippingSettingsApiStore();
+    $user = adminShippingSettingsApiUser();
+
+    $this->actingAs($user)
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones")
+        ->assertOk()
+        ->assertJsonPath('data.0.name', 'DACH')
+        ->assertJsonPath('data.0.rates.0.config_json.currency', $store->default_currency);
+
+    $createResponse = $this->actingAs($user)
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [
+            'name' => 'Nordics API',
+            'countries_json' => ['se', 'no'],
+            'regions_json' => ['stockholm'],
+        ])
+        ->assertCreated()
+        ->assertJsonPath('data.name', 'Nordics API')
+        ->assertJsonPath('data.countries_json.0', 'SE')
+        ->assertJsonPath('data.regions_json.0', 'STOCKHOLM');
+
+    $zone = ShippingZone::withoutGlobalScopes()->findOrFail($createResponse->json('data.id'));
+
+    $this->actingAs($user)
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}", [
+            'name' => 'Nordics and Baltics API',
+            'countries_json' => ['SE', 'DK'],
+            'regions_json' => [],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.name', 'Nordics and Baltics API')
+        ->assertJsonPath('data.countries_json.1', 'DK');
+
+    $rateResponse = $this->actingAs($user)
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [
+            'name' => 'API Express',
+            'type' => 'flat',
+            'config_json' => [
+                'price_amount' => 1200,
+                'currency' => $store->default_currency,
+            ],
+            'is_active' => true,
+        ])
+        ->assertCreated()
+        ->assertJsonPath('data.name', 'API Express')
+        ->assertJsonPath('data.type', 'flat')
+        ->assertJsonPath('data.config_json.price_amount', 1200);
+
+    $rate = ShippingRate::withoutGlobalScopes()->findOrFail($rateResponse->json('data.id'));
+
+    expect($zone->refresh()->countries_json)->toBe(['SE', 'DK'])
+        ->and($rate->type)->toBe(ShippingRateType::Flat)
+        ->and($rate->config_json['amount'])->toBe(1200);
+});
+
+test('admin shipping settings api enforces token abilities and store scope', function (): void {
+    $store = adminShippingSettingsApiStore();
+    $otherStore = Store::factory()->create();
+    $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
+    $readToken = adminShippingSettingsApiToken($store, ['read-settings']);
+    $writeToken = adminShippingSettingsApiToken($store, ['write-settings']);
+    $otherStoreToken = adminShippingSettingsApiToken($otherStore, ['read-settings']);
+
+    $this->withToken($readToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones")
+        ->assertOk();
+
+    $this->withToken($readToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [
+            'name' => 'Read Only Zone',
+            'countries_json' => ['FI'],
+        ])
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones")
+        ->assertForbidden();
+
+    $this->withToken($writeToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [
+            'name' => 'Token Rate',
+            'type' => 'carrier',
+            'config_json' => [
+                'price_amount' => 999,
+            ],
+        ])
+        ->assertCreated();
+});
+
+test('admin shipping settings api validates countries overlaps and rate types', function (): void {
+    $store = adminShippingSettingsApiStore();
+    $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
+
+    $this->actingAs(adminShippingSettingsApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [
+            'name' => 'Overlap API',
+            'countries_json' => ['DE'],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['countries_json']);
+
+    $this->actingAs(adminShippingSettingsApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [
+            'name' => 'Invalid Rate',
+            'type' => 'rocket',
+            'config_json' => [],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['type']);
 });
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php
M /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/ShippingRateController.php
M /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/ShippingZoneResource.php
M /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/ShippingRateResource.php
M /Users/fabianwesner/Herd/shop/routes/api.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminShippingSettingsApiTest.php

@@ -4,2 +4,3 @@

+use App\Enums\ShippingRateType;
 use Illuminate\Http\Request;
@@ -16,3 +17,52 @@
     {
-        return parent::toArray($request);
+        return [
+            'id' => $this->id,
+            'zone_id' => $this->zone_id,
+            'name' => $this->name,
+            'type' => $this->type?->value,
+            'config_json' => $this->publicConfig(),
+            'is_active' => $this->is_active,
+        ];
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function publicConfig(): array
+    {
+        $config = $this->config_json ?? [];
+        $currency = strtoupper((string) data_get($config, 'currency', data_get($this->zone?->store, 'default_currency', 'EUR')));
+
+        if (in_array($this->type, [ShippingRateType::Flat, ShippingRateType::Carrier], true)) {
+            return [
+                'price_amount' => (int) data_get($config, 'price_amount', data_get($config, 'amount', 0)),
+                'currency' => $currency,
+            ];
+        }
+
+        if ($this->type === ShippingRateType::Weight) {
+            return [
+                'currency' => $currency,
+                'tiers' => collect(data_get($config, 'tiers', data_get($config, 'ranges', [])))
+                    ->map(fn (array $tier): array => [
+                        'min_weight_g' => (int) data_get($tier, 'min_weight_g', data_get($tier, 'min_g', 0)),
+                        'max_weight_g' => data_get($tier, 'max_weight_g', data_get($tier, 'max_g')),
+                        'price_amount' => (int) data_get($tier, 'price_amount', data_get($tier, 'amount', 0)),
+                    ])
+                    ->values()
+                    ->all(),
+            ];
+        }
+
+        return [
+            'currency' => $currency,
+            'tiers' => collect(data_get($config, 'tiers', data_get($config, 'ranges', [])))
+                ->map(fn (array $tier): array => [
+                    'min_order_amount' => (int) data_get($tier, 'min_order_amount', data_get($tier, 'min_amount', 0)),
+                    'max_order_amount' => data_get($tier, 'max_order_amount', data_get($tier, 'max_amount')),
+                    'price_amount' => (int) data_get($tier, 'price_amount', data_get($tier, 'amount', 0)),
+                ])
+                ->values()
+                ->all(),
+        ];
     }

@@ -2,6 +2,146 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\ShippingRateType;
+use App\Models\ShippingRate;
+use App\Models\ShippingZone;
+use App\Models\Store;
+use App\Models\User;
+use App\Services\WebhookService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminShippingSettingsApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminShippingSettingsApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminShippingSettingsApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Shipping settings integration', $abilities);
+}
+
+test('admin shipping settings api lists creates updates zones and adds rates', function (): void {
+    $store = adminShippingSettingsApiStore();
+    $user = adminShippingSettingsApiUser();
+
+    $this->actingAs($user)
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones")
+        ->assertOk()
+        ->assertJsonPath('data.0.name', 'DACH')
+        ->assertJsonPath('data.0.rates.0.config_json.currency', $store->default_currency);
+
+    $createResponse = $this->actingAs($user)
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [
+            'name' => 'Nordics API',
+            'countries_json' => ['se', 'no'],
+            'regions_json' => ['stockholm'],
+        ])
+        ->assertCreated()
+        ->assertJsonPath('data.name', 'Nordics API')
+        ->assertJsonPath('data.countries_json.0', 'SE')
+        ->assertJsonPath('data.regions_json.0', 'STOCKHOLM');
+
+    $zone = ShippingZone::withoutGlobalScopes()->findOrFail($createResponse->json('data.id'));
+
+    $this->actingAs($user)
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}", [
+            'name' => 'Nordics and Baltics API',
+            'countries_json' => ['SE', 'DK'],
+            'regions_json' => [],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.name', 'Nordics and Baltics API')
+        ->assertJsonPath('data.countries_json.1', 'DK');
+
+    $rateResponse = $this->actingAs($user)
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [
+            'name' => 'API Express',
+            'type' => 'flat',
+            'config_json' => [
+                'price_amount' => 1200,
+                'currency' => $store->default_currency,
+            ],
+            'is_active' => true,
+        ])
+        ->assertCreated()
+        ->assertJsonPath('data.name', 'API Express')
+        ->assertJsonPath('data.type', 'flat')
+        ->assertJsonPath('data.config_json.price_amount', 1200);

-    $response->assertStatus(200);
+    $rate = ShippingRate::withoutGlobalScopes()->findOrFail($rateResponse->json('data.id'));
+
+    expect($zone->refresh()->countries_json)->toBe(['SE', 'DK'])
+        ->and($rate->type)->toBe(ShippingRateType::Flat)
+        ->and($rate->config_json['amount'])->toBe(1200);
+});
+
+test('admin shipping settings api enforces token abilities and store scope', function (): void {
+    $store = adminShippingSettingsApiStore();
+    $otherStore = Store::factory()->create();
+    $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
+    $readToken = adminShippingSettingsApiToken($store, ['read-settings']);
+    $writeToken = adminShippingSettingsApiToken($store, ['write-settings']);
+    $otherStoreToken = adminShippingSettingsApiToken($otherStore, ['read-settings']);
+
+    $this->withToken($readToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones")
+        ->assertOk();
+
+    $this->withToken($readToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [
+            'name' => 'Read Only Zone',
+            'countries_json' => ['FI'],
+        ])
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones")
+        ->assertForbidden();
+
+    $this->withToken($writeToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [
+            'name' => 'Token Rate',
+            'type' => 'carrier',
+            'config_json' => [
+                'price_amount' => 999,
+            ],
+        ])
+        ->assertCreated();
+});
+
+test('admin shipping settings api validates countries overlaps and rate types', function (): void {
+    $store = adminShippingSettingsApiStore();
+    $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail();
+
+    $this->actingAs(adminShippingSettingsApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones", [
+            'name' => 'Overlap API',
+            'countries_json' => ['DE'],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['countries_json']);
+
+    $this->actingAs(adminShippingSettingsApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/shipping/zones/{$zone->getKey()}/rates", [
+            'name' => 'Invalid Rate',
+            'type' => 'rocket',
+            'config_json' => [],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['type']);
 });

@@ -5,3 +5,10 @@
 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\ShippingZoneResource;
+use App\Models\ShippingZone;
+use App\Models\Store;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Validation\ValidationException;

@@ -9,3 +16,144 @@
 {
-    //
+    public function index(Request $request, Store $store): AnonymousResourceCollection
+    {
+        $this->authorizeStore($request, $store);
+
+        $zones = ShippingZone::withoutGlobalScopes()
+            ->with(['rates' => fn ($query) => $query->withoutGlobalScopes()->orderBy('id'), 'store'])
+            ->where('store_id', $store->getKey())
+            ->orderBy('id')
+            ->get();
+
+        return ShippingZoneResource::collection($zones);
+    }
+
+    public function store(Request $request, Store $store): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $this->validatePayload($request, $store);
+
+        $zone = ShippingZone::withoutGlobalScopes()->create([
+            'store_id' => $store->getKey(),
+            ...$this->attributes($validated),
+        ]);
+
+        return ShippingZoneResource::make($this->loadZone($zone))
+            ->response()
+            ->setStatusCode(201);
+    }
+
+    public function update(Request $request, Store $store, ShippingZone $shippingZone): ShippingZoneResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessZoneBelongsToStore($shippingZone, $store);
+
+        $validated = $this->validatePayload($request, $store, $shippingZone);
+
+        $shippingZone->update($this->attributes($validated));
+
+        return ShippingZoneResource::make($this->loadZone($shippingZone->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 abortUnlessZoneBelongsToStore(ShippingZone $zone, Store $store): void
+    {
+        abort_unless((int) $zone->store_id === $store->getKey(), 404);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function validatePayload(Request $request, Store $store, ?ShippingZone $zone = null): array
+    {
+        $validated = $request->validate([
+            'name' => ['required', 'string', 'max:255'],
+            'countries_json' => ['required', 'array', 'min:1'],
+            'countries_json.*' => ['required', 'string', 'size:2'],
+            'regions_json' => ['sometimes', 'array'],
+            'regions_json.*' => ['string', 'max:20'],
+        ]);
+
+        $countries = $this->countryCodes($validated['countries_json']);
+
+        if ($countries === []) {
+            throw ValidationException::withMessages([
+                'countries_json' => __('Enter at least one ISO country code.'),
+            ]);
+        }
+
+        $overlap = ShippingZone::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->when($zone instanceof ShippingZone, fn (Builder $query) => $query->whereKeyNot($zone->getKey()))
+            ->get()
+            ->first(fn (ShippingZone $existing): bool => array_intersect($countries, $existing->countries_json ?? []) !== []);
+
+        if ($overlap instanceof ShippingZone) {
+            throw ValidationException::withMessages([
+                'countries_json' => __('One or more countries already belong to another shipping zone.'),
+            ]);
+        }
+
+        $validated['countries_json'] = $countries;
+        $validated['regions_json'] = $this->regionCodes($validated['regions_json'] ?? []);
+
+        return $validated;
+    }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<string, mixed>
+     */
+    private function attributes(array $validated): array
+    {
+        return [
+            'name' => $validated['name'],
+            'countries_json' => $validated['countries_json'],
+            'regions_json' => $validated['regions_json'],
+        ];
+    }
+
+    /**
+     * @param  list<string>  $countries
+     * @return list<string>
+     */
+    private function countryCodes(array $countries): array
+    {
+        return collect($countries)
+            ->map(fn (string $country): string => strtoupper(trim($country)))
+            ->filter(fn (string $country): bool => preg_match('/^[A-Z]{2}$/', $country) === 1)
+            ->unique()
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @param  list<string>  $regions
+     * @return list<string>
+     */
+    private function regionCodes(array $regions): array
+    {
+        return collect($regions)
+            ->map(fn (string $region): string => strtoupper(trim($region)))
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
+    }
+
+    private function loadZone(ShippingZone $zone): ShippingZone
+    {
+        return $zone->load([
+            'rates' => fn ($query) => $query->withoutGlobalScopes()->orderBy('id'),
+            'store',
+        ]);
+    }
 }

@@ -4,4 +4,11 @@

+use App\Enums\ShippingRateType;
 use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\ShippingRateResource;
+use App\Models\ShippingRate;
+use App\Models\ShippingZone;
+use App\Models\Store;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;

@@ -9,3 +16,90 @@
 {
-    //
+    public function store(Request $request, Store $store, ShippingZone $shippingZone): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessZoneBelongsToStore($shippingZone, $store);
+
+        $validated = $request->validate([
+            'name' => ['required', 'string', 'max:255'],
+            'type' => ['required', Rule::in(array_map(fn (ShippingRateType $type): string => $type->value, ShippingRateType::cases()))],
+            'config_json' => ['required', 'array'],
+            'config_json.price_amount' => ['nullable', 'integer', 'min:0'],
+            'config_json.amount' => ['nullable', 'integer', 'min:0'],
+            'config_json.currency' => ['nullable', 'string', 'size:3'],
+            'config_json.tiers' => ['nullable', 'array'],
+            'config_json.tiers.*.min_weight_g' => ['nullable', 'integer', 'min:0'],
+            'config_json.tiers.*.max_weight_g' => ['nullable', 'integer', 'min:1'],
+            'config_json.tiers.*.min_order_amount' => ['nullable', 'integer', 'min:0'],
+            'config_json.tiers.*.max_order_amount' => ['nullable', 'integer', 'min:1'],
+            'config_json.tiers.*.price_amount' => ['nullable', 'integer', 'min:0'],
+            'config_json.ranges' => ['nullable', 'array'],
+            'is_active' => ['sometimes', 'boolean'],
+        ]);
+
+        $rate = ShippingRate::withoutGlobalScopes()->create([
+            'zone_id' => $shippingZone->getKey(),
+            'name' => $validated['name'],
+            'type' => ShippingRateType::from($validated['type']),
+            'config_json' => $this->normalizeConfig($validated['type'], $request->input('config_json', []), $store),
+            'is_active' => (bool) ($validated['is_active'] ?? true),
+        ]);
+
+        return ShippingRateResource::make($rate->load('zone.store'))
+            ->response()
+            ->setStatusCode(201);
+    }
+
+    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 abortUnlessZoneBelongsToStore(ShippingZone $zone, Store $store): void
+    {
+        abort_unless((int) $zone->store_id === $store->getKey(), 404);
+    }
+
+    /**
+     * @param  array<string, mixed>  $config
+     * @return array<string, mixed>
+     */
+    private function normalizeConfig(string $type, array $config, Store $store): array
+    {
+        if (in_array($type, [ShippingRateType::Flat->value, ShippingRateType::Carrier->value], true)) {
+            return [
+                'amount' => (int) ($config['price_amount'] ?? $config['amount'] ?? 0),
+                'currency' => strtoupper((string) ($config['currency'] ?? $store->default_currency)),
+            ];
+        }
+
+        if ($type === ShippingRateType::Weight->value) {
+            return [
+                'currency' => strtoupper((string) ($config['currency'] ?? $store->default_currency)),
+                'ranges' => collect($config['tiers'] ?? $config['ranges'] ?? [])
+                    ->map(fn (array $tier): array => [
+                        'min_g' => (int) ($tier['min_weight_g'] ?? $tier['min_g'] ?? 0),
+                        'max_g' => array_key_exists('max_weight_g', $tier) ? $tier['max_weight_g'] : ($tier['max_g'] ?? null),
+                        'amount' => (int) ($tier['price_amount'] ?? $tier['amount'] ?? 0),
+                    ])
+                    ->values()
+                    ->all(),
+            ];
+        }
+
+        return [
+            'currency' => strtoupper((string) ($config['currency'] ?? $store->default_currency)),
+            'ranges' => collect($config['tiers'] ?? $config['ranges'] ?? [])
+                ->map(fn (array $tier): array => [
+                    'min_amount' => (int) ($tier['min_order_amount'] ?? $tier['min_amount'] ?? 0),
+                    'max_amount' => array_key_exists('max_order_amount', $tier) ? $tier['max_order_amount'] : ($tier['max_amount'] ?? null),
+                    'amount' => (int) ($tier['price_amount'] ?? $tier['amount'] ?? 0),
+                ])
+                ->values()
+                ->all(),
+        ];
+    }
 }

@@ -12,2 +12,4 @@
 use App\Http\Controllers\Api\Admin\V1\SearchIndexController as AdminSearchIndexController;
+use App\Http\Controllers\Api\Admin\V1\ShippingRateController as AdminShippingRateController;
+use App\Http\Controllers\Api\Admin\V1\ShippingZoneController as AdminShippingZoneController;
 use App\Http\Controllers\Api\Admin\V1\TaxSettingsController as AdminTaxSettingsController;
@@ -102,2 +104,3 @@
             Route::get('search/status', [AdminSearchIndexController::class, 'status'])->name('search.status');
+            Route::get('shipping/zones', [AdminShippingZoneController::class, 'index'])->name('shipping.zones.index');
             Route::get('tax/settings', [AdminTaxSettingsController::class, 'show'])->name('tax.settings.show');
@@ -107,2 +110,5 @@
             Route::post('search/reindex', [AdminSearchIndexController::class, 'reindex'])->name('search.reindex');
+            Route::post('shipping/zones', [AdminShippingZoneController::class, 'store'])->name('shipping.zones.store');
+            Route::put('shipping/zones/{shippingZone}', [AdminShippingZoneController::class, 'update'])->name('shipping.zones.update');
+            Route::post('shipping/zones/{shippingZone}/rates', [AdminShippingRateController::class, 'store'])->name('shipping.zones.rates.store');
             Route::put('tax/settings', [AdminTaxSettingsController::class, 'update'])->name('tax.settings.update');

@@ -16,3 +16,10 @@
     {
-        return parent::toArray($request);
+        return [
+            'id' => $this->id,
+            'store_id' => $this->store_id,
+            'name' => $this->name,
+            'countries_json' => $this->countries_json ?? [],
+            'regions_json' => $this->regions_json ?? [],
+            'rates' => ShippingRateResource::collection($this->whenLoaded('rates')),
+        ];
     }


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


The shipping API slice is patched. I’m formatting and then running its focused tests; the main risks are enum/resource casts and overlap validation.


Output:
Chunk ID: a5a12b
Wall time: 0.3637 seconds
Process exited with code 0
Original token count: 5
Output:
{"result":"pass"}


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


php artisan test --compact tests/Feature/Api/AdminShippingSettingsApiTest.php


Output:
Chunk ID: 0b6866
Wall time: 1.0020 seconds
Process running with session ID 70947
Original token count: 0
Output:


php artisan test --compact tests/Feature/Api/AdminShippingSettingsApiTest.php

  ...

  Tests:    3 passed (27 assertions)
  Duration: 1.26s


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

  ...

  Tests:    3 passed (27 assertions)
  Duration: 1.26s


The focused shipping API test passed: 3 tests, 27 assertions. I’m running the full API group and route-list check.


php artisan test --compact tests/Feature/Api


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


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

  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}/shipping/zones api.admin.v1.shipping.…
  POST      api/admin/v1/stores/{store}/shipping/zones api.admin.v1.shipping.…
  PUT       api/admin/v1/stores/{store}/shipping/zones/{shippingZone} api.adm…
  POST      api/admin/v1/stores/{store}/shipping/zones/{shippingZone}/rates a…
  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 [29] routes


Output:
Chunk ID: 3b6120
Wall time: 1.0010 seconds
Process running with session ID 28713
Original token count: 0
Output:


Output:
Chunk ID: 47c520
Wall time: 0.1511 seconds
Process exited with code 0
Original token count: 610
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}/shipping/zones api.admin.v1.shipping.…
  POST      api/admin/v1/stores/{store}/shipping/zones api.admin.v1.shipping.…
  PUT       api/admin/v1/stores/{store}/shipping/zones/{shippingZone} api.adm…
  POST      api/admin/v1/stores/{store}/shipping/zones/{shippingZone}/rates a…
  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 [29] routes


Output:
Chunk ID: c5fbe5
Wall time: 4.5857 seconds
Process exited with code 0
Original token count: 28
Output:

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

  Tests:    45 passed (364 assertions)
  Duration: 14.11s


The API group is green with shipping settings: 45 tests, 364 assertions, and route-list shows 29 admin API routes. I’m updating progress, then I’ll run the full suite.


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, tax settings read/update, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, search status/reindex endpoints, analytics summary, shipping zone/rate settings, tax settings read/update, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
@@
 - 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.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 nested array validation, scoped nested resources, database transactions, JSON API assertions, and Pest 4 docs before the admin shipping settings API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/ShippingZoneController --no-interaction`, `php artisan make:controller Api/Admin/V1/ShippingRateController --no-interaction`, `php artisan make:resource Admin/V1/ShippingZoneResource --no-interaction`, `php artisan make:resource Admin/V1/ShippingRateResource --no-interaction`, and `php artisan make:test Api/AdminShippingSettingsApiTest --pest --no-interaction` created the shipping settings API controllers/resources and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin shipping settings API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminShippingSettingsApiTest.php` passed after the admin shipping settings API changes: 3 tests, 27 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin shipping settings API changes: 45 tests, 364 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 29 admin API routes, including shipping zone list/create/update and rate create.
@@
-- 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 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, shipping zone/rate settings, tax settings read/update, and order fulfillment/refund mutations.
@@
 - 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.
+- The shipping settings API accepts spec-facing `price_amount`/`tiers` rate config keys and normalizes them to the existing `amount`/`ranges` config consumed by `ShippingCalculator`.
@@
-- 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.
+- Admin REST surfaces for 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/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.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/search/analytics/shipping/tax 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


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

@@ -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, tax settings read/update, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, search status/reindex endpoints, analytics summary, shipping zone/rate settings, tax settings read/update, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
 | 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. |
@@ -375,2 +375,8 @@
 - 2026-05-04: `php artisan test --compact` passed after the admin tax settings API changes: 229 tests, 1294 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 nested array validation, scoped nested resources, database transactions, JSON API assertions, and Pest 4 docs before the admin shipping settings API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/ShippingZoneController --no-interaction`, `php artisan make:controller Api/Admin/V1/ShippingRateController --no-interaction`, `php artisan make:resource Admin/V1/ShippingZoneResource --no-interaction`, `php artisan make:resource Admin/V1/ShippingRateResource --no-interaction`, and `php artisan make:test Api/AdminShippingSettingsApiTest --pest --no-interaction` created the shipping settings API controllers/resources and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin shipping settings API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminShippingSettingsApiTest.php` passed after the admin shipping settings API changes: 3 tests, 27 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin shipping settings API changes: 45 tests, 364 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 29 admin API routes, including shipping zone list/create/update and rate create.

@@ -408,3 +414,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, tax settings read/update, 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, shipping zone/rate settings, 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.
@@ -415,2 +421,3 @@
 - 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.
+- The shipping settings API accepts spec-facing `price_amount`/`tiers` rate config keys and normalizes them to the existing `amount`/`ranges` config consumed by `ShippingCalculator`.
 - 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.
@@ -441,3 +448,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 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.
+- Admin REST surfaces for 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.
@@ -447,2 +454,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/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.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/search/analytics/shipping/tax 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}}


Progress reflects the shipping settings API and its config normalization. I’m running the full suite now.


Output:
Chunk ID: de1f20
Wall time: 1.0017 seconds
Process running with session ID 77643
Original token count: 1
Output:

  .


Output:
Chunk ID: a023de
Wall time: 5.0009 seconds
Process running with session ID 77643
Original token count: 9
Output:
...................................


Output:
Chunk ID: c805ab
Wall time: 5.0021 seconds
Process running with session ID 77643
Original token count: 20
Output:
........................................
  ...................................


Output:
Chunk ID: 90964b
Wall time: 5.0013 seconds
Process running with session ID 77643
Original token count: 31
Output:
.........................................
  ............................................................................
  .


Output:
Chunk ID: ece1f7
Wall time: 0.0005 seconds
Process exited with code 0
Original token count: 17
Output:
...

  Tests:    232 passed (1321 assertions)
  Duration: 56.11s


Full suite passed after shipping settings: 232 tests, 1321 assertions. I’m recording that result and committing the slice.


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminShippingSettingsApiTest.php` passed after the admin shipping settings API changes: 3 tests, 27 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin shipping settings API changes: 45 tests, 364 assertions.
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 29 admin API routes, including shipping zone list/create/update and rate create.
+- 2026-05-04: `php artisan test --compact` passed after the admin shipping settings API changes: 232 tests, 1321 assertions.

 ## Decisions
*** End Patch


@@ -381,2 +381,3 @@
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 29 admin API routes, including shipping zone list/create/update and rate create.
+- 2026-05-04: `php artisan test --compact` passed after the admin shipping settings API changes: 232 tests, 1321 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}}


 routes/api.php    |  6 ++++++
 specs/progress.md | 16 ++++++++++++----
 2 files changed, 18 insertions(+), 4 deletions(-)


 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/ShippingRateController.php
?? app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php
?? app/Http/Resources/Admin/V1/ShippingRateResource.php
?? app/Http/Resources/Admin/V1/ShippingZoneResource.php
?? tests/Feature/Api/AdminShippingSettingsApiTest.php


Output:
Chunk ID: 114923
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 126
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/ShippingRateController.php
?? app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php
?? app/Http/Resources/Admin/V1/ShippingRateResource.php
?? app/Http/Resources/Admin/V1/ShippingZoneResource.php
?? tests/Feature/Api/AdminShippingSettingsApiTest.php


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


Output:
Chunk ID: 5b2b71
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 31
Output:
 routes/api.php    |  6 ++++++
 specs/progress.md | 16 ++++++++++++----
 2 files changed, 18 insertions(+), 4 deletions(-)


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

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: 8490fe
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


git diff --stat -- app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php app/Http/Controllers/Api/Admin/V1/ShippingRateController.php app/Http/Resources/Admin/V1/ShippingZoneResource.php app/Http/Resources/Admin/V1/ShippingRateResource.php tests/Feature/Api/AdminShippingSettingsApiTest.php


 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/ShippingRateController.php
?? app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php
?? app/Http/Resources/Admin/V1/ShippingRateResource.php
?? app/Http/Resources/Admin/V1/ShippingZoneResource.php
?? tests/Feature/Api/AdminShippingSettingsApiTest.php


Output:
Chunk ID: 86ab4c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 84
Output:
 M routes/api.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/ShippingRateController.php
?? app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php
?? app/Http/Resources/Admin/V1/ShippingRateResource.php
?? app/Http/Resources/Admin/V1/ShippingZoneResource.php
?? tests/Feature/Api/AdminShippingSettingsApiTest.php


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


git add app/Http/Controllers/Api/Admin/V1/ShippingRateController.php app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php app/Http/Resources/Admin/V1/ShippingRateResource.php app/Http/Resources/Admin/V1/ShippingZoneResource.php routes/api.php specs/progress.md tests/Feature/Api/AdminShippingSettingsApiTest.php


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


 .../Api/Admin/V1/ShippingRateController.php        | 105 ++++++++++++++
 .../Api/Admin/V1/ShippingZoneController.php        | 159 +++++++++++++++++++++
 .../Resources/Admin/V1/ShippingRateResource.php    |  69 +++++++++
 .../Resources/Admin/V1/ShippingZoneResource.php    |  26 ++++
 routes/api.php                                     |   6 +
 specs/progress.md                                  |  16 ++-
 tests/Feature/Api/AdminShippingSettingsApiTest.php | 147 +++++++++++++++++++
 7 files changed, 524 insertions(+), 4 deletions(-)


A  app/Http/Controllers/Api/Admin/V1/ShippingRateController.php
A  app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php
A  app/Http/Resources/Admin/V1/ShippingRateResource.php
A  app/Http/Resources/Admin/V1/ShippingZoneResource.php
M  routes/api.php
M  specs/progress.md
A  tests/Feature/Api/AdminShippingSettingsApiTest.php