Output:
Chunk ID: 085ffc
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 3373
Output:
        $attributes = [
            'title' => $validated['title'],
            'description_html' => $validated['description_html'] ?? null,
            'vendor' => $validated['vendor'] ?? null,
            'product_type' => $validated['product_type'] ?? null,
            'status' => $validated['status'] ?? ProductStatus::Draft->value,
            'tags' => $this->tagList($validated['tags'] ?? []),
            'options' => $this->optionPayload($validated),
            'variants' => $this->variantPayloads($validated['variants'], $store, true),
        ];

        if (filled($validated['handle'] ?? null)) {
            $attributes['handle'] = Str::slug((string) $validated['handle']);
        }

        return $attributes;
    }

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

        if (array_key_exists('tags', $validated)) {
            $attributes['tags'] = $this->tagList($validated['tags']);
        }

        if (filled($validated['handle'] ?? null)) {
            $attributes['handle'] = Str::slug((string) $validated['handle']);
        }

        return $attributes;
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>
     */
    private function optionPayload(array $validated): array
    {
        $declaredOptions = collect($validated['options'] ?? []);

        if ($declaredOptions->isEmpty()) {
            $declaredOptions = collect($validated['variants'] ?? [])
                ->flatMap(fn (array $variant): array => $variant['option_values'] ?? [])
                ->pluck('option_name')
                ->filter()
                ->unique()
                ->values()
                ->map(fn (string $name, int $position): array => [
                    'name' => $name,
                    'position' => $position + 1,
                ]);
        }

        return $declaredOptions
            ->map(function (array $option, int $position) use ($validated): array {
                $name = trim((string) $option['name']);
                $values = collect($option['values'] ?? [])
                    ->merge($this->variantOptionValues($validated['variants'] ?? [], $name))
                    ->map(fn (mixed $value): string => trim((string) $value))
                    ->filter()
                    ->unique()
                    ->values();

                return [
                    'name' => $name,
                    'position' => (int) ($option['position'] ?? ($position + 1)),
                    'values' => $values
                        ->map(fn (string $value, int $valuePosition): array => [
                            'value' => $value,
                            'position' => $valuePosition + 1,
                        ])
                        ->all(),
                ];
            })
            ->filter(fn (array $option): bool => $option['name'] !== '')
            ->values()
            ->all();
    }

    /**
     * @param  array<int, array<string, mixed>>  $variants
     * @return list<string>
     */
    private function variantOptionValues(array $variants, string $optionName): array
    {
        return collect($variants)
            ->flatMap(fn (array $variant): array => $variant['option_values'] ?? [])
            ->filter(fn (array $value): bool => ($value['option_name'] ?? null) === $optionName)
            ->pluck('value')
            ->values()
            ->all();
    }

    /**
     * @param  array<int, array<string, mixed>>  $variants
     * @return array<int, array<string, mixed>>
     */
    private function variantPayloads(array $variants, Store $store, bool $creating = false): array
    {
        return collect($variants)
            ->values()
            ->map(fn (array $variant, int $position): array => [
                'sku' => trim((string) $variant['sku']),
                'barcode' => $variant['barcode'] ?? null,
                'price_amount' => (int) $variant['price_amount'],
                'compare_at_amount' => array_key_exists('compare_at_amount', $variant) ? $variant['compare_at_amount'] : null,
                'currency' => strtoupper((string) ($variant['currency'] ?? $store->default_currency)),
                'weight_g' => array_key_exists('weight_g', $variant) ? $variant['weight_g'] : null,
                'requires_shipping' => (bool) data_get($variant, 'requires_shipping', true),
                'is_default' => (bool) $variant['is_default'],
                'position' => (int) data_get($variant, 'position', $position),
                'status' => data_get($variant, 'status', VariantStatus::Active->value),
                'quantity_on_hand' => data_get($variant, 'inventory.quantity_on_hand', $creating ? 0 : null),
                'inventory_policy' => data_get($variant, 'inventory.policy', $creating ? 'deny' : null),
                'options' => $this->variantOptions($variant),
            ])
            ->all();
    }

    /**
     * @param  array<string, mixed>  $variant
     * @return array<string, string>
     */
    private function variantOptions(array $variant): array
    {
        return collect($variant['option_values'] ?? [])
            ->mapWithKeys(fn (array $optionValue): array => [
                (string) $optionValue['option_name'] => (string) $optionValue['value'],
            ])
            ->all();
    }

    /**
     * @param  array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>  $optionPayload
     */
    private function replaceProductOptions(Product $product, array $optionPayload): void
    {
        $syncedOptionIds = [];

        foreach ($optionPayload as $optionData) {
            $option = ProductOption::withoutGlobalScopes()
                ->where('product_id', $product->getKey())
                ->where('position', $optionData['position'])
                ->first()
                ?? $product->options()->create([
                    'name' => $optionData['name'],
                    'position' => $optionData['position'],
                ]);

            $option->forceFill([
                'name' => $optionData['name'],
                'position' => $optionData['position'],
            ])->save();

            $syncedOptionIds[] = $option->getKey();
            $syncedValueIds = [];

            foreach ($optionData['values'] as $valueData) {
                $value = ProductOptionValue::withoutGlobalScopes()
                    ->where('product_option_id', $option->getKey())
                    ->where('position', $valueData['position'])
                    ->first()
                    ?? $option->values()->create([
                        'value' => $valueData['value'],
                        'position' => $valueData['position'],
                    ]);

                $value->forceFill([
                    'value' => $valueData['value'],
                    'position' => $valueData['position'],
                ])->save();

                $syncedValueIds[] = $value->getKey();
            }

            ProductOptionValue::withoutGlobalScopes()
                ->where('product_option_id', $option->getKey())
                ->when($syncedValueIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedValueIds))
                ->delete();
        }

        ProductOption::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->when($syncedOptionIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedOptionIds))
            ->delete();
    }

    /**
     * @param  array<int, array<string, mixed>>  $variants
     */
    private function syncProductVariants(Product $product, Store $store, array $variants): void
    {
        $variantPayloads = $this->variantPayloads($variants, $store);
        $syncedVariantIds = [];

        foreach ($variantPayloads as $position => $variantData) {
            $variantId = $variants[$position]['id'] ?? null;
            $attributes = Arr::only($variantData, [
                'sku',
                'barcode',
                'price_amount',
                'compare_at_amount',
                'currency',
                'weight_g',
                'requires_shipping',
                'is_default',
                'position',
                'status',
            ]);
            $variant = $variantId
                ? ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->findOrFail($variantId)
                : ProductVariant::withoutGlobalScopes()->create([
                    'product_id' => $product->getKey(),
                    ...$attributes,
                ]);

            if ($variantId) {
                $variant->forceFill($attributes)->save();
            }

            $this->syncVariantInventory($variant, $store, $variantData);
            $variant->optionValues()->sync($this->optionValueIdsForVariant($product, $variantData['options'] ?? []));
            $syncedVariantIds[] = $variant->getKey();
        }

        ProductVariant::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->when($syncedVariantIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedVariantIds))
            ->get()
            ->each(function (ProductVariant $variant): void {
                if ($this->variantHasOrderLines($variant)) {
                    $variant->forceFill(['status' => VariantStatus::Archived])->save();

                    return;
                }

                $variant->delete();
            });
    }

    /**
     * @param  array<string, mixed>  $variantData
     */
    private function syncVariantInventory(ProductVariant $variant, Store $store, array $variantData): void
    {
        $inventory = InventoryItem::withoutGlobalScopes()
            ->where('variant_id', $variant->getKey())
            ->first();
        $attributes = [];

        if ($variantData['quantity_on_hand'] !== null) {
            $attributes['quantity_on_hand'] = (int) $variantData['quantity_on_hand'];
        }

        if ($variantData['inventory_policy'] !== null) {
            $attributes['policy'] = $variantData['inventory_policy'];
        }

        if ($inventory instanceof InventoryItem) {
            if ($attributes !== []) {
                $inventory->forceFill($attributes)->save();
            }

            return;
        }

        InventoryItem::withoutGlobalScopes()->create([
            'store_id' => $store->getKey(),
            'variant_id' => $variant->getKey(),
            'quantity_on_hand' => (int) ($variantData['quantity_on_hand'] ?? 0),
            'quantity_reserved' => 0,
            'policy' => $variantData['inventory_policy'] ?? 'deny',
        ]);
    }

    /**
     * @param  array<string, string>  $options
     * @return list<int>
     */
    private function optionValueIdsForVariant(Product $product, array $options): array
    {
        if ($options === []) {
            return [];
        }

        return collect($options)
            ->map(function (string $value, string $optionName) use ($product): int {
                $valueId = ProductOptionValue::withoutGlobalScopes()
                    ->where('value', $value)
                    ->whereHas('option', function (Builder $query) use ($product, $optionName): void {
                        $query
                            ->withoutGlobalScopes()
                            ->where('product_id', $product->getKey())
                            ->where('name', $optionName);
                    })
                    ->value('id');

                if ($valueId === null) {
                    throw new InvalidArgumentException("Variant option selection [{$optionName}: {$value}] is invalid for this product.");
                }

                return (int) $valueId;
            })
            ->values()
            ->all();
    }

    private function variantHasOrderLines(ProductVariant $variant): bool
    {
        return Schema::hasTable('order_lines')
            && DB::table('order_lines')->where('variant_id', $variant->getKey())->exists();
    }

    /**
     * @param  array<int, mixed>  $tags
     * @return list<string>
     */
    private function tagList(array $tags): array
    {
        return collect($tags)
            ->map(fn (mixed $tag): string => trim((string) $tag))
            ->filter()
            ->unique()
            ->values()
            ->all();
    }

    /**
     * @param  array<int, mixed>  $values
     * @return list<int>
     */
    private function integerList(array $values): array
    {
        return collect($values)
            ->map(fn (mixed $value): int => (int) $value)
            ->unique()
            ->values()
            ->all();
    }

    private function nextMediaPosition(Product $product): int
    {
        $position = ProductMedia::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->max('position');

        return $position === null ? 0 : ((int) $position) + 1;
    }

    private function mediaTypeForContentType(string $contentType): MediaType


sed -n '1,260p' app/Http/Requests/Api/Admin/V1/StoreProductRequest.php

StoreProductRequest.php

<?php

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

use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;

class StoreProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        $store = $this->routeStore();

        return [
            'title' => ['required', 'string', 'max:255'],
            'handle' => [
                'sometimes',
                'nullable',
                'string',
                'max:255',
                'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
                Rule::unique('products', 'handle')->where('store_id', $store->getKey()),
            ],
            'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'],
            'vendor' => ['sometimes', 'nullable', 'string', 'max:255'],
            'product_type' => ['sometimes', 'nullable', 'string', 'max:255'],
            'status' => ['sometimes', Rule::in(['draft', 'active'])],
            'tags' => ['sometimes', 'array', 'max:50'],
            'tags.*' => ['string', 'max:255'],
            'options' => ['sometimes', 'array', 'max:3'],
            'options.*.name' => ['required_with:options', 'string', 'max:255'],
            'options.*.position' => ['required_with:options', 'integer', 'min:1', 'max:3'],
            'options.*.values' => ['sometimes', 'array', 'max:100'],
            'options.*.values.*' => ['string', 'max:255'],
            'variants' => ['required', 'array', 'min:1', 'max:100'],
            'variants.*.sku' => ['required', 'string', 'max:255'],
            'variants.*.barcode' => ['sometimes', 'nullable', 'string', 'max:255'],
            'variants.*.price_amount' => ['required', 'integer', 'min:0'],
            'variants.*.compare_at_amount' => ['sometimes', 'nullable', 'integer', 'min:0'],
            'variants.*.currency' => ['sometimes', 'string', 'size:3'],
            'variants.*.weight_g' => ['sometimes', 'nullable', 'integer', 'min:0'],
            'variants.*.requires_shipping' => ['sometimes', 'boolean'],
            'variants.*.is_default' => ['required', 'boolean'],
            'variants.*.position' => ['sometimes', 'integer', 'min:0'],
            'variants.*.status' => ['sometimes', Rule::in(['active', 'archived'])],
            'variants.*.option_values' => ['sometimes', 'array'],
            'variants.*.option_values.*.option_name' => ['required_with:variants.*.option_values', 'string', 'max:255'],
            'variants.*.option_values.*.value' => ['required_with:variants.*.option_values', 'string', 'max:255'],
            'variants.*.inventory' => ['sometimes', 'array'],
            'variants.*.inventory.quantity_on_hand' => ['sometimes', 'integer', 'min:0'],
            'variants.*.inventory.policy' => ['sometimes', Rule::in(['deny', 'continue'])],
            'collections' => ['sometimes', 'array'],
            'collections.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->getKey())],
        ];
    }

    /**
     * @return array<int, callable>
     */
    public function after(): array
    {
        return [
            function (Validator $validator): void {
                $this->validateVariantDefaults($validator);
                $this->validateVariantPrices($validator);
                $this->validateVariantSkus($validator);
            },
        ];
    }

    private function routeStore(): Store
    {
        $store = $this->route('store');

        return $store instanceof Store ? $store : Store::query()->findOrFail($store);
    }

    private function validateVariantDefaults(Validator $validator): void
    {
        $variants = $this->input('variants', []);

        if (! is_array($variants)) {
            return;
        }

        $defaultCount = collect($variants)
            ->filter(fn (mixed $variant): bool => is_array($variant) && (bool) ($variant['is_default'] ?? false))
            ->count();

        if ($defaultCount !== 1) {
            $validator->errors()->add('variants', __('Exactly one product variant must be marked as default.'));
        }
    }

    private function validateVariantPrices(Validator $validator): void
    {
        foreach ($this->input('variants', []) as $index => $variant) {
            if (! is_array($variant) || ! isset($variant['compare_at_amount'])) {
                continue;
            }

            $compareAtAmount = $variant['compare_at_amount'];

            if ($compareAtAmount === null || $compareAtAmount === '') {
                continue;
            }

            if ((int) $compareAtAmount <= (int) ($variant['price_amount'] ?? 0)) {
                $validator->errors()->add("variants.{$index}.compare_at_amount", __('The compare at amount must be greater than the price amount.'));
            }
        }
    }

    private function validateVariantSkus(Validator $validator): void
    {
        $skus = collect($this->input('variants', []))
            ->map(fn (mixed $variant): string => is_array($variant) ? trim((string) ($variant['sku'] ?? '')) : '')
            ->filter()
            ->values();

        if ($skus->duplicates()->isNotEmpty()) {
            $validator->errors()->add('variants.0.sku', __('Each variant SKU must be unique for this store.'));

            return;
        }

        $conflictingSku = ProductVariant::withoutGlobalScopes()
            ->whereIn('sku', $skus->all())
            ->whereHas('product', function (Builder $query): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $this->routeStore()->getKey());
            })
            ->value('sku');

        if ($conflictingSku !== null) {
            $validator->errors()->add('variants.0.sku', __('The SKU [:sku] is already used in this store.', ['sku' => $conflictingSku]));
        }
    }
}


Output:
Chunk ID: cdeb01
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1530
Output:
<?php

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

use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;

class StoreProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        $store = $this->routeStore();

        return [
            'title' => ['required', 'string', 'max:255'],
            'handle' => [
                'sometimes',
                'nullable',
                'string',
                'max:255',
                'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
                Rule::unique('products', 'handle')->where('store_id', $store->getKey()),
            ],
            'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'],
            'vendor' => ['sometimes', 'nullable', 'string', 'max:255'],
            'product_type' => ['sometimes', 'nullable', 'string', 'max:255'],
            'status' => ['sometimes', Rule::in(['draft', 'active'])],
            'tags' => ['sometimes', 'array', 'max:50'],
            'tags.*' => ['string', 'max:255'],
            'options' => ['sometimes', 'array', 'max:3'],
            'options.*.name' => ['required_with:options', 'string', 'max:255'],
            'options.*.position' => ['required_with:options', 'integer', 'min:1', 'max:3'],
            'options.*.values' => ['sometimes', 'array', 'max:100'],
            'options.*.values.*' => ['string', 'max:255'],
            'variants' => ['required', 'array', 'min:1', 'max:100'],
            'variants.*.sku' => ['required', 'string', 'max:255'],
            'variants.*.barcode' => ['sometimes', 'nullable', 'string', 'max:255'],
            'variants.*.price_amount' => ['required', 'integer', 'min:0'],
            'variants.*.compare_at_amount' => ['sometimes', 'nullable', 'integer', 'min:0'],
            'variants.*.currency' => ['sometimes', 'string', 'size:3'],
            'variants.*.weight_g' => ['sometimes', 'nullable', 'integer', 'min:0'],
            'variants.*.requires_shipping' => ['sometimes', 'boolean'],
            'variants.*.is_default' => ['required', 'boolean'],
            'variants.*.position' => ['sometimes', 'integer', 'min:0'],
            'variants.*.status' => ['sometimes', Rule::in(['active', 'archived'])],
            'variants.*.option_values' => ['sometimes', 'array'],
            'variants.*.option_values.*.option_name' => ['required_with:variants.*.option_values', 'string', 'max:255'],
            'variants.*.option_values.*.value' => ['required_with:variants.*.option_values', 'string', 'max:255'],
            'variants.*.inventory' => ['sometimes', 'array'],
            'variants.*.inventory.quantity_on_hand' => ['sometimes', 'integer', 'min:0'],
            'variants.*.inventory.policy' => ['sometimes', Rule::in(['deny', 'continue'])],
            'collections' => ['sometimes', 'array'],
            'collections.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->getKey())],
        ];
    }

    /**
     * @return array<int, callable>
     */
    public function after(): array
    {
        return [
            function (Validator $validator): void {
                $this->validateVariantDefaults($validator);
                $this->validateVariantPrices($validator);
                $this->validateVariantSkus($validator);
            },
        ];
    }

    private function routeStore(): Store
    {
        $store = $this->route('store');

        return $store instanceof Store ? $store : Store::query()->findOrFail($store);
    }

    private function validateVariantDefaults(Validator $validator): void
    {
        $variants = $this->input('variants', []);

        if (! is_array($variants)) {
            return;
        }

        $defaultCount = collect($variants)
            ->filter(fn (mixed $variant): bool => is_array($variant) && (bool) ($variant['is_default'] ?? false))
            ->count();

        if ($defaultCount !== 1) {
            $validator->errors()->add('variants', __('Exactly one product variant must be marked as default.'));
        }
    }

    private function validateVariantPrices(Validator $validator): void
    {
        foreach ($this->input('variants', []) as $index => $variant) {
            if (! is_array($variant) || ! isset($variant['compare_at_amount'])) {
                continue;
            }

            $compareAtAmount = $variant['compare_at_amount'];

            if ($compareAtAmount === null || $compareAtAmount === '') {
                continue;
            }

            if ((int) $compareAtAmount <= (int) ($variant['price_amount'] ?? 0)) {
                $validator->errors()->add("variants.{$index}.compare_at_amount", __('The compare at amount must be greater than the price amount.'));
            }
        }
    }

    private function validateVariantSkus(Validator $validator): void
    {
        $skus = collect($this->input('variants', []))
            ->map(fn (mixed $variant): string => is_array($variant) ? trim((string) ($variant['sku'] ?? '')) : '')
            ->filter()
            ->values();

        if ($skus->duplicates()->isNotEmpty()) {
            $validator->errors()->add('variants.0.sku', __('Each variant SKU must be unique for this store.'));

            return;
        }

        $conflictingSku = ProductVariant::withoutGlobalScopes()
            ->whereIn('sku', $skus->all())
            ->whereHas('product', function (Builder $query): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $this->routeStore()->getKey());
            })
            ->value('sku');

        if ($conflictingSku !== null) {
            $validator->errors()->add('variants.0.sku', __('The SKU [:sku] is already used in this store.', ['sku' => $conflictingSku]));
        }
    }
}


Output:
Chunk ID: 67283b
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1948
Output:
<?php

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

use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;

class UpdateProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        $store = $this->routeStore();
        $product = $this->routeProduct();

        return [
            'title' => ['sometimes', 'string', 'max:255'],
            'handle' => [
                'sometimes',
                'nullable',
                'string',
                'max:255',
                'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
                Rule::unique('products', 'handle')
                    ->where('store_id', $store->getKey())
                    ->ignore($product?->getKey()),
            ],
            'description_html' => ['sometimes', 'nullable', 'string', 'max:65535'],
            'vendor' => ['sometimes', 'nullable', 'string', 'max:255'],
            'product_type' => ['sometimes', 'nullable', 'string', 'max:255'],
            'status' => ['sometimes', Rule::in(['draft', 'active', 'archived'])],
            'tags' => ['sometimes', 'array', 'max:50'],
            'tags.*' => ['string', 'max:255'],
            'options' => ['sometimes', 'array', 'max:3'],
            'options.*.name' => ['required_with:options', 'string', 'max:255'],
            'options.*.position' => ['required_with:options', 'integer', 'min:1', 'max:3'],
            'options.*.values' => ['sometimes', 'array', 'max:100'],
            'options.*.values.*' => ['string', 'max:255'],
            'variants' => ['sometimes', 'array', 'min:1', 'max:100'],
            'variants.*.id' => ['sometimes', 'integer'],
            'variants.*.sku' => ['required_with:variants', 'string', 'max:255'],
            'variants.*.barcode' => ['sometimes', 'nullable', 'string', 'max:255'],
            'variants.*.price_amount' => ['required_with:variants', 'integer', 'min:0'],
            'variants.*.compare_at_amount' => ['sometimes', 'nullable', 'integer', 'min:0'],
            'variants.*.currency' => ['sometimes', 'string', 'size:3'],
            'variants.*.weight_g' => ['sometimes', 'nullable', 'integer', 'min:0'],
            'variants.*.requires_shipping' => ['sometimes', 'boolean'],
            'variants.*.is_default' => ['required_with:variants', 'boolean'],
            'variants.*.position' => ['sometimes', 'integer', 'min:0'],
            'variants.*.status' => ['sometimes', Rule::in(['active', 'archived'])],
            'variants.*.option_values' => ['sometimes', 'array'],
            'variants.*.option_values.*.option_name' => ['required_with:variants.*.option_values', 'string', 'max:255'],
            'variants.*.option_values.*.value' => ['required_with:variants.*.option_values', 'string', 'max:255'],
            'variants.*.inventory' => ['sometimes', 'array'],
            'variants.*.inventory.quantity_on_hand' => ['sometimes', 'integer', 'min:0'],
            'variants.*.inventory.policy' => ['sometimes', Rule::in(['deny', 'continue'])],
            'collections' => ['sometimes', 'array'],
            'collections.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->getKey())],
        ];
    }

    /**
     * @return array<int, callable>
     */
    public function after(): array
    {
        return [
            function (Validator $validator): void {
                $this->validateVariantDefaults($validator);
                $this->validateVariantPrices($validator);
                $this->validateVariantSkus($validator);
                $this->validateVariantIds($validator);
            },
        ];
    }

    private function routeStore(): Store
    {
        $store = $this->route('store');

        return $store instanceof Store ? $store : Store::query()->findOrFail($store);
    }

    private function routeProduct(): ?Product
    {
        $product = $this->route('product');

        return $product instanceof Product ? $product : null;
    }

    private function validateVariantDefaults(Validator $validator): void
    {
        if (! $this->has('variants')) {
            return;
        }

        $variants = $this->input('variants', []);

        if (! is_array($variants)) {
            return;
        }

        $defaultCount = collect($variants)
            ->filter(fn (mixed $variant): bool => is_array($variant) && (bool) ($variant['is_default'] ?? false))
            ->count();

        if ($defaultCount !== 1) {
            $validator->errors()->add('variants', __('Exactly one product variant must be marked as default.'));
        }
    }

    private function validateVariantPrices(Validator $validator): void
    {
        foreach ($this->input('variants', []) as $index => $variant) {
            if (! is_array($variant) || ! isset($variant['compare_at_amount'])) {
                continue;
            }

            $compareAtAmount = $variant['compare_at_amount'];

            if ($compareAtAmount === null || $compareAtAmount === '') {
                continue;
            }

            if ((int) $compareAtAmount <= (int) ($variant['price_amount'] ?? 0)) {
                $validator->errors()->add("variants.{$index}.compare_at_amount", __('The compare at amount must be greater than the price amount.'));
            }
        }
    }

    private function validateVariantSkus(Validator $validator): void
    {
        if (! $this->has('variants')) {
            return;
        }

        $skus = collect($this->input('variants', []))
            ->map(fn (mixed $variant): string => is_array($variant) ? trim((string) ($variant['sku'] ?? '')) : '')
            ->filter()
            ->values();

        if ($skus->duplicates()->isNotEmpty()) {
            $validator->errors()->add('variants.0.sku', __('Each variant SKU must be unique for this store.'));

            return;
        }

        $product = $this->routeProduct();

        $conflictingSku = ProductVariant::withoutGlobalScopes()
            ->whereIn('sku', $skus->all())
            ->whereHas('product', function (Builder $query): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $this->routeStore()->getKey());
            })
            ->when($product instanceof Product, function (Builder $query) use ($product): void {
                $query->where('product_id', '!=', $product->getKey());
            })
            ->value('sku');

        if ($conflictingSku !== null) {
            $validator->errors()->add('variants.0.sku', __('The SKU [:sku] is already used in this store.', ['sku' => $conflictingSku]));
        }
    }

    private function validateVariantIds(Validator $validator): void
    {
        $product = $this->routeProduct();

        if (! $product instanceof Product) {
            return;
        }

        $variantIds = collect($this->input('variants', []))
            ->pluck('id')
            ->filter()
            ->map(fn (mixed $variantId): int => (int) $variantId)
            ->values();

        if ($variantIds->isEmpty()) {
            return;
        }

        $validCount = ProductVariant::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->whereIn('id', $variantIds->all())
            ->count();

        if ($validCount !== $variantIds->count()) {
            $validator->errors()->add('variants', __('Variant IDs must belong to the product being updated.'));
        }
    }
}


sed -n '1,180p' app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php


sed -n '1,180p' app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php

<?php

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

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;

class CreateProductMediaUploadRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'filename' => ['required', 'string', 'max:255'],
            'content_type' => ['required', Rule::in(['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'video/mp4'])],
            'byte_size' => ['required', 'integer', 'min:1'],
        ];
    }

    /**
     * @return array<int, callable>
     */
    public function after(): array
    {
        return [
            function (Validator $validator): void {
                $this->validateExtension($validator);
                $this->validateByteSize($validator);
            },
        ];
    }

    private function validateExtension(Validator $validator): void
    {
        $extension = strtolower(pathinfo((string) $this->input('filename'), PATHINFO_EXTENSION));
        $allowedExtensions = match ((string) $this->input('content_type')) {
            'image/jpeg' => ['jpg', 'jpeg'],
            'image/png' => ['png'],
            'image/webp' => ['webp'],
            'image/avif' => ['avif'],
            'video/mp4' => ['mp4'],
            default => [],
        };

        if ($extension === '' || ! in_array($extension, $allowedExtensions, true)) {
            $validator->errors()->add('filename', __('The filename extension must match the content type.'));
        }
    }

    private function validateByteSize(Validator $validator): void
    {
        $limit = $this->input('content_type') === 'video/mp4'
            ? 500 * 1024 * 1024
            : 50 * 1024 * 1024;

        if ((int) $this->input('byte_size') > $limit) {
            $validator->errors()->add('byte_size', __('The uploaded media file is too large.'));
        }
    }
}


Output:
Chunk ID: 397988
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 524
Output:
<?php

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

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;

class CreateProductMediaUploadRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'filename' => ['required', 'string', 'max:255'],
            'content_type' => ['required', Rule::in(['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'video/mp4'])],
            'byte_size' => ['required', 'integer', 'min:1'],
        ];
    }

    /**
     * @return array<int, callable>
     */
    public function after(): array
    {
        return [
            function (Validator $validator): void {
                $this->validateExtension($validator);
                $this->validateByteSize($validator);
            },
        ];
    }

    private function validateExtension(Validator $validator): void
    {
        $extension = strtolower(pathinfo((string) $this->input('filename'), PATHINFO_EXTENSION));
        $allowedExtensions = match ((string) $this->input('content_type')) {
            'image/jpeg' => ['jpg', 'jpeg'],
            'image/png' => ['png'],
            'image/webp' => ['webp'],
            'image/avif' => ['avif'],
            'video/mp4' => ['mp4'],
            default => [],
        };

        if ($extension === '' || ! in_array($extension, $allowedExtensions, true)) {
            $validator->errors()->add('filename', __('The filename extension must match the content type.'));
        }
    }

    private function validateByteSize(Validator $validator): void
    {
        $limit = $this->input('content_type') === 'video/mp4'
            ? 500 * 1024 * 1024
            : 50 * 1024 * 1024;

        if ((int) $this->input('byte_size') > $limit) {
            $validator->errors()->add('byte_size', __('The uploaded media file is too large.'));
        }
    }
}


The slice is formatted and the API tests are green. I’m committing only the product API/progress files and leaving the unrelated `.playwright-mcp` logs uncommitted.


git add app/Http/Controllers/Api/Admin/V1/ProductController.php app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php app/Http/Requests/Api/Admin/V1/StoreProductRequest.php app/Http/Requests/Api/Admin/V1/UpdateProductRequest.php routes/api.php tests/Feature/Api/AdminCatalogApiTest.php specs/progress.md


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


 .../Controllers/Api/Admin/V1/ProductController.php | 565 +++++++++++++++++++++
 .../Admin/V1/CreateProductMediaUploadRequest.php   |  68 +++
 .../Requests/Api/Admin/V1/StoreProductRequest.php  | 152 ++++++
 .../Requests/Api/Admin/V1/UpdateProductRequest.php | 206 ++++++++
 routes/api.php                                     |   7 +
 specs/progress.md                                  |  13 +-
 tests/Feature/Api/AdminCatalogApiTest.php          | 176 +++++++
 7 files changed, 1183 insertions(+), 4 deletions(-)


Output:
Chunk ID: 6403f2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 129
Output:
 .../Controllers/Api/Admin/V1/ProductController.php | 565 +++++++++++++++++++++
 .../Admin/V1/CreateProductMediaUploadRequest.php   |  68 +++
 .../Requests/Api/Admin/V1/StoreProductRequest.php  | 152 ++++++
 .../Requests/Api/Admin/V1/UpdateProductRequest.php | 206 ++++++++
 routes/api.php                                     |   7 +
 specs/progress.md                                  |  13 +-
 tests/Feature/Api/AdminCatalogApiTest.php          | 176 +++++++
 7 files changed, 1183 insertions(+), 4 deletions(-)


M  app/Http/Controllers/Api/Admin/V1/ProductController.php
A  app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php
A  app/Http/Requests/Api/Admin/V1/StoreProductRequest.php
A  app/Http/Requests/Api/Admin/V1/UpdateProductRequest.php
M  routes/api.php
M  specs/progress.md
M  tests/Feature/Api/AdminCatalogApiTest.php
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T13-33-19-511Z.log


Output:
Chunk ID: fcc10f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 125
Output:
M  app/Http/Controllers/Api/Admin/V1/ProductController.php
A  app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php
A  app/Http/Requests/Api/Admin/V1/StoreProductRequest.php
A  app/Http/Requests/Api/Admin/V1/UpdateProductRequest.php
M  routes/api.php
M  specs/progress.md
M  tests/Feature/Api/AdminCatalogApiTest.php
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T13-33-19-511Z.log


git commit -m "Add admin product write API"


git commit -m "Add admin product write API"

[2026-05-04-codex-5-5-goal 49077b8a] Add admin product write API
 7 files changed, 1183 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php
 create mode 100644 app/Http/Requests/Api/Admin/V1/StoreProductRequest.php
 create mode 100644 app/Http/Requests/Api/Admin/V1/UpdateProductRequest.php


Output:
Chunk ID: 3c9cab
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 89
Output:
[2026-05-04-codex-5-5-goal 49077b8a] Add admin product write API
 7 files changed, 1183 insertions(+), 4 deletions(-)
 create mode 100644 app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php
 create mode 100644 app/Http/Requests/Api/Admin/V1/StoreProductRequest.php
 create mode 100644 app/Http/Requests/Api/Admin/V1/UpdateProductRequest.php


Output:
Chunk ID: f8d9aa
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 42
Output:
?? .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-04T13-33-19-511Z.log


Output:
Chunk ID: d6e81e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 3
Output:
49077b8a


Committed the product API fix as `49077b8a Add admin product write API`. The only remaining worktree noise is three untracked `.playwright-mcp` console logs, which I’m leaving out of commits.


Audit progress notes and specs for remaining incomplete/deferred items

Implement missing admin product write API routes from Spec 02

Run focused catalog API and affected feature tests

Update progress notes and commit the product API slice

Continue final completion audit for any remaining real gaps

Explanation: Committed product write API coverage in 49077b8a. Continuing the completion audit instead of stopping at the fixed gap.


Output: Plan updated


I’m back in audit mode. The next pass is route/API completeness first, because that already found one real gap; I’m comparing Spec 02 endpoint summaries against `route:list` before deciding whether the remaining `partial` labels are stale or still backed by missing work.


rg -n "#### .*/api/|\| (GET|POST|PUT|DELETE|PATCH) \| `/api|Endpoint Summary|Scope note|deferred|Not Implemented|501" specs/02-API-ROUTES.md


find app/Http/Controllers/Api -type f | sort


/bin/zsh
-lc
find tests/Feature/Api -type f | sort


Output:
Chunk ID: b61f4a
Wall time: 0.0000 seconds
Process exited with code 1
Original token count: 5
Output:
zsh:1: unmatched "


Output:
Chunk ID: 360da8
Wall time: 0.1724 seconds
Process exited with code 0
Original token count: 1426
Output:

  POST      account/logout .................................... account.logout
  POST      admin/logout ........................................ admin.logout
  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…
  POST      api/admin/v1/stores/{store}/exports/orders api.admin.v1.exports.o…
  GET|HEAD  api/admin/v1/stores/{store}/exports/{dataExport} api.admin.v1.exp…
  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 …
  POST      api/admin/v1/stores/{store}/products api.admin.v1.products.store …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  PUT       api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  DELETE    api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  POST      api/admin/v1/stores/{store}/products/{product}/media/presign-upload api.admin.v1.products.media.presign-upload › Api\Admin\V1\ProductController@presignUpl…
  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}/settings api.admin.v1.settings.show  …
  PUT       api/admin/v1/stores/{store}/settings api.admin.v1.settings.update…
  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…
  POST      api/admin/v1/stores/{store}/themes api.admin.v1.themes.store › Ap…
  POST      api/admin/v1/stores/{store}/themes/{theme}/publish api.admin.v1.t…
  PUT       api/admin/v1/stores/{store}/themes/{theme}/settings api.admin.v1.…
  GET|HEAD  api/apps/v1/stores/{store}/customers api.apps.v1.customers.index …
  GET|HEAD  api/apps/v1/stores/{store}/orders api.apps.v1.orders.index › Api\…
  GET|HEAD  api/apps/v1/stores/{store}/products api.apps.v1.products.index › …
  POST      api/storefront/v1/analytics/events api.storefront.v1.analytics.ev…
  POST      api/storefront/v1/carts api.storefront.v1.carts.store › Api\Store…
  GET|HEAD  api/storefront/v1/carts/{cart} api.storefront.v1.carts.show › Api…
  POST      api/storefront/v1/carts/{cart}/lines api.storefront.v1.carts.line…
  PUT       api/storefront/v1/carts/{cart}/lines/{cartLine} api.storefront.v1…
  DELETE    api/storefront/v1/carts/{cart}/lines/{cartLine} api.storefront.v1…
  POST      api/storefront/v1/checkouts api.storefront.v1.checkouts.store › A…
  GET|HEAD  api/storefront/v1/checkouts/{checkout} api.storefront.v1.checkout…
  PUT       api/storefront/v1/checkouts/{checkout}/address api.storefront.v1.…
  POST      api/storefront/v1/checkouts/{checkout}/apply-discount api.storefr…
  DELETE    api/storefront/v1/checkouts/{checkout}/discount api.storefront.v1…
  POST      api/storefront/v1/checkouts/{checkout}/pay api.storefront.v1.chec…
  PUT       api/storefront/v1/checkouts/{checkout}/payment-method api.storefr…
  PUT       api/storefront/v1/checkouts/{checkout}/shipping-method api.storef…
  GET|HEAD  api/storefront/v1/orders/{orderNumber} api.storefront.v1.orders.s…
  GET|HEAD  api/storefront/v1/search api.storefront.v1.search.index › Api\Sto…
  GET|HEAD  api/storefront/v1/search/suggest api.storefront.v1.search.suggest…
  ANY       dashboard .... dashboard › Illuminate\Routing › RedirectController
  POST      forgot-password customer.password.email › Storefront\Account\Auth…
  GET|HEAD  oauth/authorize .. oauth.authorize › Api\OAuthController@authorize
  POST      oauth/token .............. oauth.token › Api\OAuthController@token
  POST      reset-password customer.password.update › Storefront\Account\Auth…
  ANY       settings ................. Illuminate\Routing › RedirectController

                                                           Showing [68] routes


Output:
Chunk ID: 78f9df
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 371
Output:
app/Http/Controllers/Api/Admin/V1/AnalyticsSummaryController.php
app/Http/Controllers/Api/Admin/V1/CollectionController.php
app/Http/Controllers/Api/Admin/V1/CustomerController.php
app/Http/Controllers/Api/Admin/V1/DiscountController.php
app/Http/Controllers/Api/Admin/V1/OrderController.php
app/Http/Controllers/Api/Admin/V1/OrderExportController.php
app/Http/Controllers/Api/Admin/V1/OrderFulfillmentController.php
app/Http/Controllers/Api/Admin/V1/OrderRefundController.php
app/Http/Controllers/Api/Admin/V1/PageController.php
app/Http/Controllers/Api/Admin/V1/ProductController.php
app/Http/Controllers/Api/Admin/V1/SearchIndexController.php
app/Http/Controllers/Api/Admin/V1/ShippingRateController.php
app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php
app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php
app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php
app/Http/Controllers/Api/Admin/V1/ThemeController.php
app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php
app/Http/Controllers/Api/Apps/V1/DeferredEndpointController.php
app/Http/Controllers/Api/OAuthController.php
app/Http/Controllers/Api/Storefront/V1/AnalyticsEventController.php
app/Http/Controllers/Api/Storefront/V1/CartController.php
app/Http/Controllers/Api/Storefront/V1/CartLineController.php
app/Http/Controllers/Api/Storefront/V1/CheckoutController.php
app/Http/Controllers/Api/Storefront/V1/OrderController.php
app/Http/Controllers/Api/Storefront/V1/SearchController.php


Output:
Chunk ID: 445f52
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 193
Output:
tests/Feature/Api/AdminAnalyticsSummaryApiTest.php
tests/Feature/Api/AdminCatalogApiTest.php
tests/Feature/Api/AdminCollectionApiTest.php
tests/Feature/Api/AdminDiscountApiTest.php
tests/Feature/Api/AdminOrderApiTest.php
tests/Feature/Api/AdminOrderExportApiTest.php
tests/Feature/Api/AdminPageApiTest.php
tests/Feature/Api/AdminSearchIndexApiTest.php
tests/Feature/Api/AdminShippingSettingsApiTest.php
tests/Feature/Api/AdminStoreSettingsApiTest.php
tests/Feature/Api/AdminTaxSettingsApiTest.php
tests/Feature/Api/AdminThemeApiTest.php
tests/Feature/Api/StorefrontAnalyticsApiTest.php
tests/Feature/Api/StorefrontCartApiTest.php
tests/Feature/Api/StorefrontCheckoutApiTest.php
tests/Feature/Api/StorefrontOrderApiTest.php
tests/Feature/Api/StorefrontSearchApiTest.php


/bin/zsh
-lc
php artisan route:list --except-vendor

  POST      account/logout .................................... account.logout
  POST      admin/logout ........................................ admin.logout
  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…
  POST      api/admin/v1/stores/{store}/exports/orders api.admin.v1.exports.o…
  GET|HEAD  api/admin/v1/stores/{store}/exports/{dataExport} api.admin.v1.exp…
  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 …
  POST      api/admin/v1/stores/{store}/products api.admin.v1.products.store …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  PUT       api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  DELETE    api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  POST      api/admin/v1/stores/{store}/products/{product}/media/presign-upload api.admin.v1.products.media.presign-upload › Api\Admin\V1\ProductController@presignUpl…
  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}/settings api.admin.v1.settings.show  …
  PUT       api/admin/v1/stores/{store}/settings api.admin.v1.settings.update…
  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…
  POST      api/admin/v1/stores/{store}/themes api.admin.v1.themes.store › Ap…
  POST      api/admin/v1/stores/{store}/themes/{theme}/publish api.admin.v1.t…
  PUT       api/admin/v1/stores/{store}/themes/{theme}/settings api.admin.v1.…
  GET|HEAD  api/apps/v1/stores/{store}/customers api.apps.v1.customers.index …
  GET|HEAD  api/apps/v1/stores/{store}/orders api.apps.v1.orders.index › Api\…
  GET|HEAD  api/apps/v1/stores/{store}/products api.apps.v1.products.index › …
  POST      api/storefront/v1/analytics/events api.storefront.v1.analytics.ev…
  POST      api/storefront/v1/carts api.storefront.v1.carts.store › Api\Store…
  GET|HEAD  api/storefront/v1/carts/{cart} api.storefront.v1.carts.show › Api…
  POST      api/storefront/v1/carts/{cart}/lines api.storefront.v1.carts.line…
  PUT       api/storefront/v1/carts/{cart}/lines/{cartLine} api.storefront.v1…
  DELETE    api/storefront/v1/carts/{cart}/lines/{cartLine} api.storefront.v1…
  POST      api/storefront/v1/checkouts api.storefront.v1.checkouts.store › A…
  GET|HEAD  api/storefront/v1/checkouts/{checkout} api.storefront.v1.checkout…
  PUT       api/storefront/v1/checkouts/{checkout}/address api.storefront.v1.…
  POST      api/storefront/v1/checkouts/{checkout}/apply-discount api.storefr…
  DELETE    api/storefront/v1/checkouts/{checkout}/discount api.storefront.v1…
  POST      api/storefront/v1/checkouts/{checkout}/pay api.storefront.v1.chec…
  PUT       api/storefront/v1/checkouts/{checkout}/payment-method api.storefr…
  PUT       api/storefront/v1/checkouts/{checkout}/shipping-method api.storef…
  GET|HEAD  api/storefront/v1/orders/{orderNumber} api.storefront.v1.orders.s…
  GET|HEAD  api/storefront/v1/search api.storefront.v1.search.index › Api\Sto…
  GET|HEAD  api/storefront/v1/search/suggest api.storefront.v1.search.suggest…
  ANY       dashboard .... dashboard › Illuminate\Routing › RedirectController
  POST      forgot-password customer.password.email › Storefront\Account\Auth…
  GET|HEAD  oauth/authorize .. oauth.authorize › Api\OAuthController@authorize
  POST      oauth/token .............. oauth.token › Api\OAuthController@token
  POST      reset-password customer.password.update › Storefront\Account\Auth…
  ANY       settings ................. Illuminate\Routing › RedirectController

                                                           Showing [68] routes


rg -n '#### .*/api/|\| (GET|POST|PUT|DELETE|PATCH) \| `/api|Endpoint Summary|Scope note|deferred|Not Implemented|501' specs/02-API-ROUTES.md


rg -n '#### .*/api/|\| (GET|POST|PUT|DELETE|PATCH) \| `/api|Endpoint Summary|Scope note|deferred|Not Implemented|501' specs/02-API-ROUTES.md

rg -n "#### .*/api/|\\| (GET|POST|PUT|DELETE|PATCH) \\| "'`/api|Endpoint Summary|Scope note|deferred|Not Implemented|501' specs/02-API-ROUTES.md

Question: #### .*/api/|\| (GET|POST|PUT|DELETE|PATCH) \| `/api|Endpoint Summary|Scope note|deferred|Not Implemented|501

209:#### POST /api/storefront/v1/carts
255:#### GET /api/storefront/v1/carts/{cartId}
313:#### POST /api/storefront/v1/carts/{cartId}/lines
354:#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}
386:#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}
451:    else Bank Transfer (deferred)
458:#### POST /api/storefront/v1/checkouts
524:#### GET /api/storefront/v1/checkouts/{checkoutId}
541:#### PUT /api/storefront/v1/checkouts/{checkoutId}/address
661:#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method
721:#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method
767:#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount
829:#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount
848:#### POST /api/storefront/v1/checkouts/{checkoutId}/pay
909:**Success response (Bank Transfer - deferred payment):** `200 OK`
958:#### GET /api/storefront/v1/orders/{orderNumber}
1019:#### GET /api/storefront/v1/search
1097:#### GET /api/storefront/v1/search/suggest
1143:#### POST /api/storefront/v1/analytics/events
1222:#### POST /api/admin/v1/platform/organizations
1263:#### POST /api/admin/v1/platform/stores
1316:#### POST /api/admin/v1/stores/{storeId}/invites
1357:#### GET /api/admin/v1/stores/{storeId}/me
1395:#### Admin Products Endpoint Summary
1399:| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
1400:| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
1401:| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
1402:| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
1403:| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
1404:| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |
1406:#### GET /api/admin/v1/stores/{storeId}/products
1457:#### POST /api/admin/v1/stores/{storeId}/products
1547:#### GET /api/admin/v1/stores/{storeId}/products/{productId}
1645:#### PUT /api/admin/v1/stores/{storeId}/products/{productId}
1672:#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}
1694:#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload
1738:#### Admin Collections Endpoint Summary
1742:| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |
1743:| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |
1744:| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |
1745:| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |
1747:#### GET /api/admin/v1/stores/{storeId}/collections
1789:#### POST /api/admin/v1/stores/{storeId}/collections
1825:#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}
1840:#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}
1862:#### Admin Orders Endpoint Summary
1866:| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |
1867:| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |
1868:| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |
1869:| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |
1871:#### GET /api/admin/v1/stores/{storeId}/orders
1928:#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}
2016:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments
2052:    "id": 501,
2077:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds
2134:#### Admin Discounts Endpoint Summary
2138:| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |
2139:| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |
2140:| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |
2141:| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |
2143:#### GET /api/admin/v1/stores/{storeId}/discounts
2192:#### POST /api/admin/v1/stores/{storeId}/discounts
2243:#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}
2258:#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}
2280:#### Admin Shipping Endpoint Summary
2284:| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |
2285:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |
2286:| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |
2287:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |
2289:#### GET /api/admin/v1/stores/{storeId}/shipping/zones
2337:#### POST /api/admin/v1/stores/{storeId}/shipping/zones
2365:#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}
2380:#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates
2416:#### Admin Tax Endpoint Summary
2420:| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
2421:| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |
2423:#### GET /api/admin/v1/stores/{storeId}/tax/settings
2460:#### PUT /api/admin/v1/stores/{storeId}/tax/settings
2494:#### Admin Themes Endpoint Summary
2498:| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
2499:| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
2500:| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |
2502:#### POST /api/admin/v1/stores/{storeId}/themes
2535:#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish
2558:#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings
2600:#### Admin Pages Endpoint Summary
2604:| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
2605:| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |
2606:| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |
2607:| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |
2609:#### GET /api/admin/v1/stores/{storeId}/pages
2648:#### POST /api/admin/v1/stores/{storeId}/pages
2678:#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}
2693:#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}
2715:#### Admin Search Endpoint Summary
2719:| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
2720:| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |
2722:#### POST /api/admin/v1/stores/{storeId}/search/reindex
2743:#### GET /api/admin/v1/stores/{storeId}/search/status
2766:#### Admin Analytics Endpoint Summary
2770:| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
2771:| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
2772:| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |
2774:#### GET /api/admin/v1/stores/{storeId}/analytics/summary
2834:#### POST /api/admin/v1/stores/{storeId}/exports/orders
2868:#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}
2905:> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.
2983:| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
2984:| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
2985:| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |
3145:    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)
3161:| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |


Output:
Chunk ID: 74bbd9
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2433
Output:
209:#### POST /api/storefront/v1/carts
255:#### GET /api/storefront/v1/carts/{cartId}
313:#### POST /api/storefront/v1/carts/{cartId}/lines
354:#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}
386:#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}
451:    else Bank Transfer (deferred)
458:#### POST /api/storefront/v1/checkouts
524:#### GET /api/storefront/v1/checkouts/{checkoutId}
541:#### PUT /api/storefront/v1/checkouts/{checkoutId}/address
661:#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method
721:#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method
767:#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount
829:#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount
848:#### POST /api/storefront/v1/checkouts/{checkoutId}/pay
909:**Success response (Bank Transfer - deferred payment):** `200 OK`
958:#### GET /api/storefront/v1/orders/{orderNumber}
1019:#### GET /api/storefront/v1/search
1097:#### GET /api/storefront/v1/search/suggest
1143:#### POST /api/storefront/v1/analytics/events
1222:#### POST /api/admin/v1/platform/organizations
1263:#### POST /api/admin/v1/platform/stores
1316:#### POST /api/admin/v1/stores/{storeId}/invites
1357:#### GET /api/admin/v1/stores/{storeId}/me
1395:#### Admin Products Endpoint Summary
1399:| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
1400:| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
1401:| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
1402:| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
1403:| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
1404:| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |
1406:#### GET /api/admin/v1/stores/{storeId}/products
1457:#### POST /api/admin/v1/stores/{storeId}/products
1547:#### GET /api/admin/v1/stores/{storeId}/products/{productId}
1645:#### PUT /api/admin/v1/stores/{storeId}/products/{productId}
1672:#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}
1694:#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload
1738:#### Admin Collections Endpoint Summary
1742:| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |
1743:| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |
1744:| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |
1745:| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |
1747:#### GET /api/admin/v1/stores/{storeId}/collections
1789:#### POST /api/admin/v1/stores/{storeId}/collections
1825:#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}
1840:#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}
1862:#### Admin Orders Endpoint Summary
1866:| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |
1867:| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |
1868:| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |
1869:| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |
1871:#### GET /api/admin/v1/stores/{storeId}/orders
1928:#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}
2016:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments
2052:    "id": 501,
2077:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds
2134:#### Admin Discounts Endpoint Summary
2138:| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |
2139:| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |
2140:| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |
2141:| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |
2143:#### GET /api/admin/v1/stores/{storeId}/discounts
2192:#### POST /api/admin/v1/stores/{storeId}/discounts
2243:#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}
2258:#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}
2280:#### Admin Shipping Endpoint Summary
2284:| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |
2285:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |
2286:| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |
2287:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |
2289:#### GET /api/admin/v1/stores/{storeId}/shipping/zones
2337:#### POST /api/admin/v1/stores/{storeId}/shipping/zones
2365:#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}
2380:#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates
2416:#### Admin Tax Endpoint Summary
2420:| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
2421:| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |
2423:#### GET /api/admin/v1/stores/{storeId}/tax/settings
2460:#### PUT /api/admin/v1/stores/{storeId}/tax/settings
2494:#### Admin Themes Endpoint Summary
2498:| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
2499:| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
2500:| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |
2502:#### POST /api/admin/v1/stores/{storeId}/themes
2535:#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish
2558:#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings
2600:#### Admin Pages Endpoint Summary
2604:| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
2605:| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |
2606:| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |
2607:| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |
2609:#### GET /api/admin/v1/stores/{storeId}/pages
2648:#### POST /api/admin/v1/stores/{storeId}/pages
2678:#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}
2693:#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}
2715:#### Admin Search Endpoint Summary
2719:| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
2720:| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |
2722:#### POST /api/admin/v1/stores/{storeId}/search/reindex
2743:#### GET /api/admin/v1/stores/{storeId}/search/status
2766:#### Admin Analytics Endpoint Summary
2770:| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
2771:| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
2772:| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |
2774:#### GET /api/admin/v1/stores/{storeId}/analytics/summary
2834:#### POST /api/admin/v1/stores/{storeId}/exports/orders
2868:#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}
2905:> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.
2983:| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
2984:| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
2985:| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |
3145:    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)
3161:| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |


sed -n '1180,1385p' specs/02-API-ROUTES.md


sed -n '1180,1385p' specs/02-API-ROUTES.md

}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `events` | array | Yes | Min: 1, max: 50 events per batch. |
| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |
| `events[].session_id` | string | Yes | Max: 100 characters. |
| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |
| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |
| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |

**Success response:** `202 Accepted`

```json
{
  "accepted": 2,
  "rejected": 0
}
```

**Error responses:**
- `422` - Validation errors (invalid event types, too many events, etc.)

---

## 3. Admin REST API

**Prefix:** `/api/admin/v1`
**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`
**Content-Type:** `application/json`

These endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.

All endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.

---

### 3.1 Platform Management (Super-Admin Only)

These endpoints are restricted to users with the `owner` (platform) role.

#### POST /api/admin/v1/platform/organizations

Create a new organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "name": "Acme Corp",
  "billing_email": "billing@acme.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `billing_email` | string | Yes | Valid email, max: 255 |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "name": "Acme Corp",
    "billing_email": "billing@acme.com",
    "created_at": "2026-02-11T10:00:00Z",
    "updated_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### POST /api/admin/v1/platform/stores

Create a new store within an organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "organization_id": 1,
  "name": "Acme Store",
  "handle": "acme",
  "default_currency": "EUR",
  "default_locale": "en",
  "timezone": "Europe/Berlin"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `organization_id` | integer | Yes | Must exist |
| `name` | string | Yes | Max: 255 |
| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |
| `default_currency` | string | Yes | Valid ISO 4217 code |
| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |
| `timezone` | string | Yes | Valid IANA timezone |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "name": "Acme Store",
    "handle": "acme",
    "status": "active",
    "default_currency": "EUR",
    "default_locale": "en",
    "timezone": "Europe/Berlin",
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

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

---

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

Invite a user to a store with a specific role.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "email": "staff@acme.com",
  "role": "staff"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | Valid email, max: 255 |
| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |

**Success response:** `201 Created`

```json
{
  "data": {
    "email": "staff@acme.com",
    "role": "staff",
    "invited_at": "2026-02-11T10:00:00Z",
    "expires_at": "2026-02-18T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions (only `owner` and `admin` can invite)
- `409` - User already a member of this store
- `422` - Validation errors

---

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

Get the current user's membership details for a store.

- **Authentication:** Sanctum token
- **Rate limit:** `api.admin` (60/min per token)

**Success response:** `200 OK`

```json
{
  "data": {
    "user_id": 3,
    "store_id": 1,
    "role": "admin",
    "email": "admin@acme.com",
    "name": "Jane Admin",
    "permissions": [
      "read-products",
      "write-products",
      "read-orders",
      "write-orders",
      "read-settings",
      "write-settings",
      "read-customers"
    ]
  }
}
```


Output:
Chunk ID: 4c878b
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1277
Output:
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `events` | array | Yes | Min: 1, max: 50 events per batch. |
| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |
| `events[].session_id` | string | Yes | Max: 100 characters. |
| `events[].client_event_id` | string | Yes | Max: 100 characters. If a `client_event_id` has already been recorded for the current store, the duplicate event is silently dropped (unique on `(store_id, client_event_id)`). |
| `events[].properties` | object | No | Freeform JSON. Max depth: 3 levels. |
| `events[].occurred_at` | string (ISO 8601) | Yes | Must not be more than 1 hour in the past or in the future. |

**Success response:** `202 Accepted`

```json
{
  "accepted": 2,
  "rejected": 0
}
```

**Error responses:**
- `422` - Validation errors (invalid event types, too many events, etc.)

---

## 3. Admin REST API

**Prefix:** `/api/admin/v1`
**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`
**Content-Type:** `application/json`

These endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.

All endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.

---

### 3.1 Platform Management (Super-Admin Only)

These endpoints are restricted to users with the `owner` (platform) role.

#### POST /api/admin/v1/platform/organizations

Create a new organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "name": "Acme Corp",
  "billing_email": "billing@acme.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `billing_email` | string | Yes | Valid email, max: 255 |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "name": "Acme Corp",
    "billing_email": "billing@acme.com",
    "created_at": "2026-02-11T10:00:00Z",
    "updated_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### POST /api/admin/v1/platform/stores

Create a new store within an organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "organization_id": 1,
  "name": "Acme Store",
  "handle": "acme",
  "default_currency": "EUR",
  "default_locale": "en",
  "timezone": "Europe/Berlin"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `organization_id` | integer | Yes | Must exist |
| `name` | string | Yes | Max: 255 |
| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |
| `default_currency` | string | Yes | Valid ISO 4217 code |
| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |
| `timezone` | string | Yes | Valid IANA timezone |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "name": "Acme Store",
    "handle": "acme",
    "status": "active",
    "default_currency": "EUR",
    "default_locale": "en",
    "timezone": "Europe/Berlin",
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

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

---

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

Invite a user to a store with a specific role.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "email": "staff@acme.com",
  "role": "staff"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | Valid email, max: 255 |
| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |

**Success response:** `201 Created`

```json
{
  "data": {
    "email": "staff@acme.com",
    "role": "staff",
    "invited_at": "2026-02-11T10:00:00Z",
    "expires_at": "2026-02-18T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions (only `owner` and `admin` can invite)
- `409` - User already a member of this store
- `422` - Validation errors

---

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

Get the current user's membership details for a store.

- **Authentication:** Sanctum token
- **Rate limit:** `api.admin` (60/min per token)

**Success response:** `200 OK`

```json
{
  "data": {
    "user_id": 3,
    "store_id": 1,
    "role": "admin",
    "email": "admin@acme.com",
    "name": "Jane Admin",
    "permissions": [
      "read-products",
      "write-products",
      "read-orders",
      "write-orders",
      "read-settings",
      "write-settings",
      "read-customers"
    ]
  }
}
```


rg -n "platform|organizations|stores.*invites|/me|manage-platform|admin REST|Sanctum|OAuth|deferred|apps" specs/09-IMPLEMENTATION-ROADMAP.md specs/06-AUTH-AND-SECURITY.md specs/progress.md -S


rg -n "platform|organizations|stores.*invites|/me|manage-platform|admin REST|Sanctum|OAuth|deferred|apps" specs/09-IMPLEMENTATION-ROADMAP.md specs/06-AUTH-AND-SECURITY.md specs/progress.md -S

rg -n 'platform|organizations|stores.*invites|/me|manage-platform|admin REST|Sanctum|OAuth|deferred|apps' specs/09-IMPLEMENTATION-ROADMAP.md specs/06-AUTH-AND-SECURITY.md specs/progress.md -S

Question: platform|organizations|stores.*invites|/me|manage-platform|admin REST|Sanctum|OAuth|deferred|apps

specs/progress.md:22:7. Search/analytics/apps/webhooks: SQLite search, event aggregation, developer token/webhook UI, delivery jobs, and tests.
specs/progress.md:29:| 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/export tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries, and data_exports. |
specs/progress.md:30:| 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`, `/forgot-password`, `/reset-password/{token}`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders`, `/account/orders/{order}`, `/account/addresses`, `/account/logout`, `/admin/login`, `/admin/forgot-password`, `/admin/reset-password/{token}`, `/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 list/detail/create/update/archive/media presign, customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, store settings read/update, search status/reindex endpoints, analytics summary, order export creation/status, shipping zone/rate settings, tax settings read/update, theme upload/publish/settings update, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. |
specs/progress.md:33:| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService` with per-store synonym expansion and stop-word removal, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `OrderExportService`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks and automatic/code order-line attribution, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, storefront cart stock-limit feedback for deny-policy inventory, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, customer address book management, customer logout session invalidation, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/develo…6156 chars truncated…authorization remains deferred per the roadmap; `/oauth/*` and `/api/apps/v1/*` now return explicit `501 Not Implemented` responses, while the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store.
specs/progress.md:674:Not complete until the final completion audit is closed. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, deterministic Spec 07 customer/address/order/payment/fulfillment/refund seed 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 account order/address/logout views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/content page/store settings/search/analytics/shipping/tax/theme/order/export API surfaces, admin product create/update/archive/media-presign API surfaces, deferred OAuth/app route stubs, all 18 Spec 08 automated browser suites, outbound webhook delivery foundations, spec-root customer password reset routes, and representative database CHECK constraint coverage are implemented.
specs/06-AUTH-AND-SECURITY.md:3:> E-commerce platform - Auth, authorization, and security controls for Laravel 12 + Livewire v4 + SQLite
specs/06-AUTH-AND-SECURITY.md:9:The platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.
specs/06-AUTH-AND-SECURITY.md:206:### 1.3 API Authentication (Sanctum)
specs/06-AUTH-AND-SECURITY.md:238:- Sanctum middleware validates the hash and resolves the associated user.
specs/06-AUTH-AND-SECURITY.md:263:| `manage-platform` | Platform-level management (super-admin only) |
specs/06-AUTH-AND-SECURITY.md:382:| OwnerOrAdmin | Owner, Admin | Settings, themes, staff, developers, navigation, apps, shipping, taxes, search, delete/archive actions |
specs/06-AUTH-AND-SECURITY.md:558:| `manage-apps` | Owner, Admin | Install and remove third-party apps |
specs/06-AUTH-AND-SECURITY.md:721:| API routes (Sanctum token) | Exempt from CSRF (token-based auth replaces CSRF need) |
specs/06-AUTH-AND-SECURITY.md:766:| OauthClient | `client_secret_encrypted` | `encrypted` | Third-party OAuth client secrets |
specs/06-AUTH-AND-SECURITY.md:922:| `paths` | `['api/*', 'sanctum/csrf-cookie']` | Only API and Sanctum CSRF cookie endpoints are CORS-enabled. Web routes are not. |
specs/06-AUTH-AND-SECURITY.md:929:| `supports_credentials` | `true` | Required for Sanctum SPA auth (cookies). |
specs/06-AUTH-AND-SECURITY.md:945:When the platform sends webhooks to external subscribers (third-party apps, integrations).
specs/06-AUTH-AND-SECURITY.md:1097:| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |
specs/06-AUTH-AND-SECURITY.md:1108:| 14 | Webhook signature verification | HMAC-SHA256 for outbound platform webhooks (no inbound payment webhooks - Mock PSP is in-process) | Required |
specs/09-IMPLEMENTATION-ROADMAP.md:3:> E-commerce platform - Step-by-step build order and exhaustive test plan for Laravel 12 + Livewire v4 + Flux UI Free + SQLite + Tailwind CSS v4
specs/09-IMPLEMENTATION-ROADMAP.md:21:| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |
specs/09-IMPLEMENTATION-ROADMAP.md:22:| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |
specs/09-IMPLEMENTATION-ROADMAP.md:24:| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |
specs/09-IMPLEMENTATION-ROADMAP.md:104:1. `create_organizations_table` - no dependencies
specs/09-IMPLEMENTATION-ROADMAP.md:237:| Manage apps | Y | Y | N | N |
specs/09-IMPLEMENTATION-ROADMAP.md:668:| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |
specs/09-IMPLEMENTATION-ROADMAP.md:675:- Bank transfer: deferred capture, returns PaymentResult with status=pending
specs/09-IMPLEMENTATION-ROADMAP.md:925:1. `create_apps_table`
specs/09-IMPLEMENTATION-ROADMAP.md:991:| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |
specs/09-IMPLEMENTATION-ROADMAP.md:1189:#### tests/Feature/Auth/SanctumTokenTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1732:| `OauthClient` | OAuth client for app |
specs/09-IMPLEMENTATION-ROADMAP.md:1733:| `OauthToken` | OAuth token for installation |
specs/09-IMPLEMENTATION-ROADMAP.md:1888:| `Auth/SanctumTokenTest` | API token feature tests |