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

namespace App\Livewire\Admin\Settings;

use App\Enums\ShippingRateType;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use App\Services\ShippingCalculator;
use App\Support\Money;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Shipping extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public ?int $editingZoneId = null;

    public string $zoneName = '';

    public string $zoneCountries = '';

    public ?int $rateZoneId = null;

    public ?int $editingRateId = null;

    public string $rateName = '';

    public string $rateType = 'flat';

    public string $rateAmount = '0.00';

    public string $minimumWeight = '0';

    public string $maximumWeight = '';

    public string $minimumOrderAmount = '0.00';

    public string $maximumOrderAmount = '';

    public bool $rateActive = true;

    public string $testCountry = 'DE';

    public string $testRegion = '';

    /**
     * @var array<string, mixed>|null
     */
    public ?array $testResult = null;

    public function mount(): void
    {
        $store = $this->store();

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

        $this->storeId = $store->getKey();
    }

    public function editZone(int $zoneId): void
    {
        $zone = $this->zone($zoneId);

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

        $this->editingZoneId = $zone->getKey();
        $this->zoneName = $zone->name;
        $this->zoneCountries = implode(', ', $zone->countries_json ?? []);
    }

    public function saveZone(): void
    {
        $store = $this->scopedStore();

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

        $validated = $this->validate([
            'zoneName' => ['required', 'string', 'max:255'],
            'zoneCountries' => ['required', 'string', 'max:255'],
        ], [], [
            'zoneName' => 'zone name',
            'zoneCountries' => 'countries',
        ]);

        $countries = $this->countryCodes($validated['zoneCountries']);

        if ($countries === []) {
            $this->addError('zoneCountries', __('Enter at least one ISO country code.'));

            return;
        }

        $payload = [
            'store_id' => $store->getKey(),
            'name' => $validated['zoneName'],
            'countries_json' => $countries,
            'regions_json' => [],
        ];

        $this->editingZoneId
            ? $this->zone($this->editingZoneId)->update($payload)
            : ShippingZone::withoutGlobalScopes()->create($payload);

        $this->resetZoneForm();

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

    public function deleteZone(int $zoneId): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->zone($zoneId)->delete();
        $this->resetZoneForm();
        $this->resetRateForm();

        session()->flash('status', 'Shipping zone deleted');
    }

    public function addRate(int $zoneId): void
    {
        $zone = $this->zone($zoneId);

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

        $this->resetRateForm();
        $this->rateZoneId = $zone->getKey();
    }

    public function editRate(int $rateId): void
    {
        $rate = $this->rate($rateId);

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

        $this->editingRateId = $rate->getKey();
        $this->rateZoneId = $rate->zone_id;
        $this->rateName = $rate->name;
        $this->rateType = $rate->type->value;
        $this->rateActive = $rate->is_active;
        $this->fillRateConfig($rate);
    }

    public function saveRate(): void
    {
        $store = $this->scopedStore();

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

        $validated = $this->validate([
            'rateZoneId' => ['required', 'integer'],
            'rateName' => ['required', 'string', 'max:255'],
            'rateType' => ['required', Rule::in(array_column(ShippingRateType::cases(), 'value'))],
            'rateAmount' => ['required', 'numeric', 'min:0'],
            'minimumWeight' => ['nullable', 'integer', 'min:0'],
            'maximumWeight' => ['nullable', 'integer', 'min:1'],
            'minimumOrderAmount' => ['nullable', 'numeric', 'min:0'],
            'maximumOrderAmount' => ['nullable', 'numeric', 'min:0'],
            'rateActive' => ['boolean'],
        ], [], [
            'rateZoneId' => 'shipping zone',
            'rateName' => 'rate name',
            'rateType' => 'rate type',
            'rateAmount' => 'rate amount',
        ]);

        $zone = $this->zone((int) $validated['rateZoneId']);
        $payload = [
            'zone_id' => $zone->getKey(),
            'name' => $validated['rateName'],
            'type' => ShippingRateType::from($validated['rateType']),
            'config_json' => $this->rateConfig(),
            'is_active' => $this->rateActive,
        ];

        $this->editingRateId
            ? $this->rate($this->editingRateId)->update($payload)
            : ShippingRate::withoutGlobalScopes()->create($payload);

        $this->resetRateForm();

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

    public function deleteRate(int $rateId): void
    {
        $this->authorize('update', $this->scopedStore());

        $this->rate($rateId)->delete();
        $this->resetRateForm();

        session()->flash('status', 'Shipping rate deleted');
    }

    public function toggleRateActive(int $rateId): void
    {
        $this->authorize('update', $this->scopedStore());

        $rate = $this->rate($rateId);
        $rate->forceFill(['is_active' => ! $rate->is_active])->save();
    }

    public function testShippingAddress(ShippingCalculator $shipping): void
    {
        $store = $this->scopedStore();

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

        $address = [
            'country' => strtoupper($this->testCountry),
            'province_code' => strtoupper($this->testRegion),
        ];
        $zone = $shipping->matchingZone($store, $address);

        $this->testResult = [
            'zone' => $zone?->name,
            'rates' => $zone instanceof ShippingZone
                ? ShippingRate::withoutGlobalScopes()
                    ->where('zone_id', $zone->getKey())
                    ->where('is_active', true)
                    ->orderBy('id')
                    ->get()
                    ->map(fn (ShippingRate $rate): string => $rate->name.' - '.$this->rateSummary($rate))
                    ->all()
                : [],
        ];
    }

    /**
     * @return Collection<int, ShippingZone>
     */
    public function zones(): Collection
    {
        return ShippingZone::withoutGlobalScopes()
            ->with(['rates' => fn ($query) => $query->withoutGlobalScopes()->orderBy('id')])
            ->where('store_id', $this->storeId)
            ->orderBy('id')
            ->get();
    }

    public function rateSummary(ShippingRate $rate): string
    {
        if ($rate->type === ShippingRateType::Carrier) {
            return 'Carrier fallback '.Money::format((int) data_get($rate->config_json, 'amount', 0), $this->scopedStore()->default_currency);
        }

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


command: sed -n '1,220p' database/seeders/ShippingSeeder.php

ShippingSeeder.php

type: read

aggregated_output: sed: database/seeders/ShippingSeeder.php: No such file or directory

status: failed


output:
Chunk ID: b2b07e
Wall time: 0.0000 seconds
Process exited with code 1
Original token count: 17
Output:
sed: database/seeders/ShippingSeeder.php: No such file or directory


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


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

aggregated_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;
    }
}

status: completed


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;
    }
}


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


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

type: search

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

query: ShippingZone|ShippingRate|shipping

path: seeders

aggregated_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(


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(


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


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

ShippingRateSeeder.php

aggregated_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],
                    ],
                ],
            ],
        ];
    }
}


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],
                    ],
                ],
            ],
        ];
    }
}


message: 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.

phase: commentary


type: message

role: assistant

text: 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.


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


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


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


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


cmd: 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.


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

type: unknown

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


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

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


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

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


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

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


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

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


status: completed

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


stdout:
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

type: update

unified_diff:
@@ -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(),
+        ];
     }

unified_diff:
@@ -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']);
 });

unified_diff:
@@ -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',
+        ]);
+    }
 }

unified_diff:
@@ -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(),
+        ];
+    }
 }

unified_diff:
@@ -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');

unified_diff:
@@ -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}}


message: 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.


text: 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"}


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

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

result: pass


cmd: 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:


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

aggregated_output:
  ...

  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


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


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


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


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


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

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


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


message: 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.


text: 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.


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
-| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, search status/reindex endpoints, analytics summary, 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