status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/DiscountController.php
+<?php
+
+namespace App\Http\Controllers\Api\Admin\V1;
+
+use App\Http\Controllers\Controller;
+use App\Http\Resources\Admin\V1\DiscountResource;
+use App\Models\Discount;
+use App\Models\Store;
+use Carbon\CarbonInterface;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Support\Str;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+
+class DiscountController extends Controller
+{
+    public function index(Request $request, Store $store): AnonymousResourceCollection
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $request->validate([
+            'type' => ['nullable', Rule::in(['code', 'automatic'])],
+            'status' => ['nullable', Rule::in(['active', 'expired', 'scheduled'])],
+            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+        ]);
+
+        $discounts = Discount::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->when(data_get($validated, 'type'), fn (Builder $query, string $type) => $query->where('type', $type))
+            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $this->applyComputedStatus($query, $status))
+            ->latest('created_at')
+            ->latest('id')
+            ->paginate((int) data_get($validated, 'per_page', 25));
+
+        return DiscountResource::collection($discounts);
+    }
+
+    public function store(Request $request, Store $store): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $this->validatePayload($request, $store);
+
+        $discount = Discount::withoutGlobalScopes()->create($this->attributesForCreate($validated, $store));
+
+        return DiscountResource::make($discount)
+            ->response()
+            ->setStatusCode(201);
+    }
+
+    public function update(Request $request, Store $store, Discount $discount): DiscountResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessDiscountBelongsToStore($discount, $store);
+
+        $validated = $this->validatePayload($request, $store, $discount);
+
+        $discount->update($this->attributesForUpdate($validated, $discount));
+
+        return DiscountResource::make($discount->refresh());
+    }
+
+    public function destroy(Request $request, Store $store, Discount $discount): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessDiscountBelongsToStore($discount, $store);
+
+        $discount->delete();
+
+        return response()->json(['message' => 'Discount deleted']);
+    }
+
+    private function authorizeStore(Request $request, Store $store): void
+    {
+        if (! $request->attributes->has('admin_api_oauth_token')) {
+            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
+        }
+
+        app()->instance('current_store', $store);
+    }
+
+    private function abortUnlessDiscountBelongsToStore(Discount $discount, Store $store): void
+    {
+        abort_unless((int) $discount->store_id === $store->getKey(), 404);
+    }
+
+    private function applyComputedStatus(Builder $query, string $status): void
+    {
+        match ($status) {
+            'active' => $query
+                ->where('status', 'active')
+                ->where('starts_at', '<=', now())
+                ->where(fn (Builder $query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', now())),
+            'expired' => $query->where(fn (Builder $query) => $query->where('status', 'expired')->orWhere('ends_at', '<=', now())),
+            'scheduled' => $query->where('starts_at', '>', now()),
+        };
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function validatePayload(Request $request, Store $store, ?Discount $discount = null): array
+    {
+        $creating = $discount === null;
+        $type = (string) $request->input('type', $discount?->type->value ?? 'code');
+        $valueType = (string) $request->input('value_type', $discount?->value_type->value ?? 'percent');
+
+        $validated = $request->validate([
+            'type' => [$creating ? 'required' : 'sometimes', Rule::in(['code', 'automatic'])],
+            'code' => [
+                Rule::requiredIf($creating && $type === 'code'),
+                'nullable',
+                'string',
+                'max:50',
+                function (string $attribute, mixed $value, \Closure $fail) use ($store, $discount): void {
+                    if ($value === null || trim((string) $value) === '') {
+                        return;
+                    }
+
+                    $normalized = Str::upper(trim((string) $value));
+
+                    if ($discount instanceof Discount && $normalized !== (string) $discount->code) {
+                        $fail(__('The discount code cannot be changed after creation.'));
+
+                        return;
+                    }
+
+                    $exists = Discount::withoutGlobalScopes()
+                        ->where('store_id', $store->getKey())
+                        ->whereRaw('lower(code) = ?', [Str::lower($normalized)])
+                        ->when($discount instanceof Discount, fn (Builder $query) => $query->whereKeyNot($discount->getKey()))
+                        ->exists();
+
+                    if ($exists) {
+                        $fail(__('The discount code has already been taken.'));
+                    }
+                },
+            ],
+            'value_type' => [$creating ? 'required' : 'sometimes', Rule::in(['fixed', 'percent', 'free_shipping'])],
+            'value_amount' => $this->valueAmountRules($creating, $valueType),
+            'starts_at' => ['sometimes', 'nullable', 'date'],
+            'ends_at' => ['sometimes', 'nullable', 'date'],
+            'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'],
+            'status' => ['sometimes', Rule::in(['draft', 'active', 'expired', 'disabled'])],
+            'rules_json' => ['sometimes', 'array'],
+            'rules_json.minimum_purchase_amount' => ['nullable', 'integer', 'min:0'],
+            'rules_json.min_purchase_amount' => ['nullable', 'integer', 'min:0'],
+            'rules_json.applicable_product_ids' => ['nullable', 'array'],
+            'rules_json.applicable_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
+            'rules_json.applicable_collection_ids' => ['nullable', 'array'],
+            'rules_json.applicable_collection_ids.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->getKey())],
+            'rules_json.customer_eligibility' => ['nullable', Rule::in(['all', 'specific_customers', 'specific_segments'])],
+            'rules_json.once_per_customer' => ['nullable', 'boolean'],
+            'rules_json.one_per_customer' => ['nullable', 'boolean'],
+        ]);
+
+        $this->validateDates($validated, $discount);
+
+        return $validated;
+    }
+
+    /**
+     * @return list<mixed>
+     */
+    private function valueAmountRules(bool $creating, string $valueType): array
+    {
+        $presence = $creating ? 'required' : 'sometimes';
+
+        return match ($valueType) {
+            'percent' => [$presence, 'integer', 'min:1', 'max:100'],
+            'free_shipping' => ['sometimes', 'nullable', 'integer', 'min:0'],
+            default => [$presence, 'integer', 'min:1'],
+        };
+    }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     */
+    private function validateDates(array $validated, ?Discount $discount): void
+    {
+        if (! array_key_exists('ends_at', $validated) || $validated['ends_at'] === null) {
+            return;
+        }
+
+        $startsAt = data_get($validated, 'starts_at', $discount?->starts_at ?? now());
+
+        if (! $startsAt instanceof CarbonInterface) {
+            $startsAt = now()->parse($startsAt);
+        }
+
+        $endsAt = now()->parse($validated['ends_at']);
+
+        if ($endsAt->lte($startsAt)) {
+            throw ValidationException::withMessages([
+                'ends_at' => __('The end date must be after the start date.'),
+            ]);
+        }
+    }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<string, mixed>
+     */
+    private function attributesForCreate(array $validated, Store $store): array
+    {
+        $type = (string) $validated['type'];
+        $valueType = (string) $validated['value_type'];
+
+        return [
+            'store_id' => $store->getKey(),
+            'type' => $type,
+            'code' => $type === 'code' ? Str::upper(trim((string) $validated['code'])) : null,
+            'value_type' => $valueType,
+            'value_amount' => $valueType === 'free_shipping' ? 0 : (int) $validated['value_amount'],
+            'starts_at' => $validated['starts_at'] ?? now(),
+            'ends_at' => $validated['ends_at'] ?? null,
+            'usage_limit' => $validated['usage_limit'] ?? null,
+            'usage_count' => 0,
+            'rules_json' => $this->rulesPayload($validated['rules_json'] ?? []),
+            'status' => $validated['status'] ?? 'active',
+        ];
+    }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<string, mixed>
+     */
+    private function attributesForUpdate(array $validated, Discount $discount): array
+    {
+        $attributes = [];
+        $valueType = (string) data_get($validated, 'value_type', $discount->value_type->value);
+
+        foreach (['type', 'value_type', 'starts_at', 'ends_at', 'usage_limit', 'status'] as $field) {
+            if (array_key_exists($field, $validated)) {
+                $attributes[$field] = $validated[$field];
+            }
+        }
+
+        if (array_key_exists('value_amount', $validated)) {
+            $attributes['value_amount'] = $valueType === 'free_shipping' ? 0 : (int) $validated['value_amount'];
+        } elseif (array_key_exists('value_type', $validated) && $valueType === 'free_shipping') {
+            $attributes['value_amount'] = 0;
+        }
+
+        if (array_key_exists('rules_json', $validated)) {
+            $attributes['rules_json'] = $this->rulesPayload($validated['rules_json']);
+        }
+
+        return $attributes;
+    }
+
+    /**
+     * @param  array<string, mixed>  $rules
+     * @return array<string, mixed>
+     */
+    private function rulesPayload(array $rules): array
+    {
+        return [
+            'min_purchase_amount' => (int) data_get($rules, 'min_purchase_amount', data_get($rules, 'minimum_purchase_amount', 0)),
+            'applicable_product_ids' => $this->integerList(data_get($rules, 'applicable_product_ids', [])),
+            'applicable_collection_ids' => $this->integerList(data_get($rules, 'applicable_collection_ids', [])),
+            'customer_eligibility' => data_get($rules, 'customer_eligibility', 'all'),
+            'one_per_customer' => (bool) data_get($rules, 'one_per_customer', data_get($rules, 'once_per_customer', false)),
+        ];
+    }
+
+    /**
+     * @return list<int>
+     */
+    private function integerList(mixed $value): array
+    {
+        return collect(is_array($value) ? $value : [])
+            ->map(fn (mixed $id): int => (int) $id)
+            ->unique()
+            ->values()
+            ->all();
+    }
+}
*** End Patch


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

type: add

content:
<?php

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

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\DiscountResource;
use App\Models\Discount;
use App\Models\Store;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;

class DiscountController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'type' => ['nullable', Rule::in(['code', 'automatic'])],
            'status' => ['nullable', Rule::in(['active', 'expired', 'scheduled'])],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $discounts = Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'type'), fn (Builder $query, string $type) => $query->where('type', $type))
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $this->applyComputedStatus($query, $status))
            ->latest('created_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return DiscountResource::collection($discounts);
    }

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

        $validated = $this->validatePayload($request, $store);

        $discount = Discount::withoutGlobalScopes()->create($this->attributesForCreate($validated, $store));

        return DiscountResource::make($discount)
            ->response()
            ->setStatusCode(201);
    }

    public function update(Request $request, Store $store, Discount $discount): DiscountResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessDiscountBelongsToStore($discount, $store);

        $validated = $this->validatePayload($request, $store, $discount);

        $discount->update($this->attributesForUpdate($validated, $discount));

        return DiscountResource::make($discount->refresh());
    }

    public function destroy(Request $request, Store $store, Discount $discount): JsonResponse
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessDiscountBelongsToStore($discount, $store);

        $discount->delete();

        return response()->json(['message' => 'Discount deleted']);
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessDiscountBelongsToStore(Discount $discount, Store $store): void
    {
        abort_unless((int) $discount->store_id === $store->getKey(), 404);
    }

    private function applyComputedStatus(Builder $query, string $status): void
    {
        match ($status) {
            'active' => $query
                ->where('status', 'active')
                ->where('starts_at', '<=', now())
                ->where(fn (Builder $query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', now())),
            'expired' => $query->where(fn (Builder $query) => $query->where('status', 'expired')->orWhere('ends_at', '<=', now())),
            'scheduled' => $query->where('starts_at', '>', now()),
        };
    }

    /**
     * @return array<string, mixed>
     */
    private function validatePayload(Request $request, Store $store, ?Discount $discount = null): array
    {
        $creating = $discount === null;
        $type = (string) $request->input('type', $discount?->type->value ?? 'code');
        $valueType = (string) $request->input('value_type', $discount?->value_type->value ?? 'percent');

        $validated = $request->validate([
            'type' => [$creating ? 'required' : 'sometimes', Rule::in(['code', 'automatic'])],
            'code' => [
                Rule::requiredIf($creating && $type === 'code'),
                'nullable',
                'string',
                'max:50',
                function (string $attribute, mixed $value, \Closure $fail) use ($store, $discount): void {
                    if ($value === null || trim((string) $value) === '') {
                        return;
                    }

                    $normalized = Str::upper(trim((string) $value));

                    if ($discount instanceof Discount && $normalized !== (string) $discount->code) {
                        $fail(__('The discount code cannot be changed after creation.'));

                        return;
                    }

                    $exists = Discount::withoutGlobalScopes()
                        ->where('store_id', $store->getKey())
                        ->whereRaw('lower(code) = ?', [Str::lower($normalized)])
                        ->when($discount instanceof Discount, fn (Builder $query) => $query->whereKeyNot($discount->getKey()))
                        ->exists();

                    if ($exists) {
                        $fail(__('The discount code has already been taken.'));
                    }
                },
            ],
            'value_type' => [$creating ? 'required' : 'sometimes', Rule::in(['fixed', 'percent', 'free_shipping'])],
            'value_amount' => $this->valueAmountRules($creating, $valueType),
            'starts_at' => ['sometimes', 'nullable', 'date'],
            'ends_at' => ['sometimes', 'nullable', 'date'],
            'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'],
            'status' => ['sometimes', Rule::in(['draft', 'active', 'expired', 'disabled'])],
            'rules_json' => ['sometimes', 'array'],
            'rules_json.minimum_purchase_amount' => ['nullable', 'integer', 'min:0'],
            'rules_json.min_purchase_amount' => ['nullable', 'integer', 'min:0'],
            'rules_json.applicable_product_ids' => ['nullable', 'array'],
            'rules_json.applicable_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $store->getKey())],
            'rules_json.applicable_collection_ids' => ['nullable', 'array'],
            'rules_json.applicable_collection_ids.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $store->getKey())],
            'rules_json.customer_eligibility' => ['nullable', Rule::in(['all', 'specific_customers', 'specific_segments'])],
            'rules_json.once_per_customer' => ['nullable', 'boolean'],
            'rules_json.one_per_customer' => ['nullable', 'boolean'],
        ]);

        $this->validateDates($validated, $discount);

        return $validated;
    }

    /**
     * @return list<mixed>
     */
    private function valueAmountRules(bool $creating, string $valueType): array
    {
        $presence = $creating ? 'required' : 'sometimes';

        return match ($valueType) {
            'percent' => [$presence, 'integer', 'min:1', 'max:100'],
            'free_shipping' => ['sometimes', 'nullable', 'integer', 'min:0'],
            default => [$presence, 'integer', 'min:1'],
        };
    }

    /**
     * @param  array<string, mixed>  $validated
     */
    private function validateDates(array $validated, ?Discount $discount): void
    {
        if (! array_key_exists('ends_at', $validated) || $validated['ends_at'] === null) {
            return;
        }

        $startsAt = data_get($validated, 'starts_at', $discount?->starts_at ?? now());

        if (! $startsAt instanceof CarbonInterface) {
            $startsAt = now()->parse($startsAt);
        }

        $endsAt = now()->parse($validated['ends_at']);

        if ($endsAt->lte($startsAt)) {
            throw ValidationException::withMessages([
                'ends_at' => __('The end date must be after the start date.'),
            ]);
        }
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForCreate(array $validated, Store $store): array
    {
        $type = (string) $validated['type'];
        $valueType = (string) $validated['value_type'];

        return [
            'store_id' => $store->getKey(),
            'type' => $type,
            'code' => $type === 'code' ? Str::upper(trim((string) $validated['code'])) : null,
            'value_type' => $valueType,
            'value_amount' => $valueType === 'free_shipping' ? 0 : (int) $validated['value_amount'],
            'starts_at' => $validated['starts_at'] ?? now(),
            'ends_at' => $validated['ends_at'] ?? null,
            'usage_limit' => $validated['usage_limit'] ?? null,
            'usage_count' => 0,
            'rules_json' => $this->rulesPayload($validated['rules_json'] ?? []),
            'status' => $validated['status'] ?? 'active',
        ];
    }

    /**
     * @param  array<string, mixed>  $validated
     * @return array<string, mixed>
     */
    private function attributesForUpdate(array $validated, Discount $discount): array
    {
        $attributes = [];
        $valueType = (string) data_get($validated, 'value_type', $discount->value_type->value);

        foreach (['type', 'value_type', 'starts_at', 'ends_at', 'usage_limit', 'status'] as $field) {
            if (array_key_exists($field, $validated)) {
                $attributes[$field] = $validated[$field];
            }
        }

        if (array_key_exists('value_amount', $validated)) {
            $attributes['value_amount'] = $valueType === 'free_shipping' ? 0 : (int) $validated['value_amount'];
        } elseif (array_key_exists('value_type', $validated) && $valueType === 'free_shipping') {
            $attributes['value_amount'] = 0;
        }

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

        return $attributes;
    }

    /**
     * @param  array<string, mixed>  $rules
     * @return array<string, mixed>
     */
    private function rulesPayload(array $rules): array
    {
        return [
            'min_purchase_amount' => (int) data_get($rules, 'min_purchase_amount', data_get($rules, 'minimum_purchase_amount', 0)),
            'applicable_product_ids' => $this->integerList(data_get($rules, 'applicable_product_ids', [])),
            'applicable_collection_ids' => $this->integerList(data_get($rules, 'applicable_collection_ids', [])),
            'customer_eligibility' => data_get($rules, 'customer_eligibility', 'all'),
            'one_per_customer' => (bool) data_get($rules, 'one_per_customer', data_get($rules, 'once_per_customer', false)),
        ];
    }

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

status: completed


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


status: completed

input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/DiscountResource.php
*** End Patch


stdout:
Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/DiscountResource.php

type: delete

content:
<?php

namespace App\Http\Resources\Admin\V1;

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

class DiscountResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return parent::toArray($request);
    }
}


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


status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/DiscountResource.php
+<?php
+
+namespace App\Http\Resources\Admin\V1;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class DiscountResource extends JsonResource
+{
+    /**
+     * Transform the resource into an array.
+     *
+     * @return array<string, mixed>
+     */
+    public function toArray(Request $request): array
+    {
+        return [
+            'id' => $this->id,
+            'store_id' => $this->store_id,
+            'type' => $this->type?->value,
+            'code' => $this->code,
+            'value_type' => $this->value_type?->value,
+            'value_amount' => $this->value_amount,
+            'starts_at' => $this->starts_at?->toIso8601String(),
+            'ends_at' => $this->ends_at?->toIso8601String(),
+            'usage_limit' => $this->usage_limit,
+            'usage_count' => $this->usage_count,
+            'rules_json' => $this->rules_json ?? [],
+            'status' => $this->status?->value,
+            'created_at' => $this->created_at?->toIso8601String(),
+            'updated_at' => $this->updated_at?->toIso8601String(),
+        ];
+    }
+}
*** End Patch


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Http/Resources/Admin/V1/DiscountResource.php

content:
<?php

namespace App\Http\Resources\Admin\V1;

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

class DiscountResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'type' => $this->type?->value,
            'code' => $this->code,
            'value_type' => $this->value_type?->value,
            'value_amount' => $this->value_amount,
            'starts_at' => $this->starts_at?->toIso8601String(),
            'ends_at' => $this->ends_at?->toIso8601String(),
            'usage_limit' => $this->usage_limit,
            'usage_count' => $this->usage_count,
            'rules_json' => $this->rules_json ?? [],
            'status' => $this->status?->value,
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}


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


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/routes/api.php
@@
 use App\Http\Controllers\Api\Admin\V1\CollectionController as AdminCollectionController;
 use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
+use App\Http\Controllers\Api\Admin\V1\DiscountController as AdminDiscountController;
@@
         Route::middleware('admin.api:write-collections')->group(function (): void {
             Route::post('collections', [AdminCollectionController::class, 'store'])->name('collections.store');
             Route::put('collections/{collection}', [AdminCollectionController::class, 'update'])->name('collections.update');
             Route::delete('collections/{collection}', [AdminCollectionController::class, 'destroy'])->name('collections.destroy');
         });

+        Route::middleware('admin.api:read-discounts')->group(function (): void {
+            Route::get('discounts', [AdminDiscountController::class, 'index'])->name('discounts.index');
+        });
+
+        Route::middleware('admin.api:write-discounts')->group(function (): void {
+            Route::post('discounts', [AdminDiscountController::class, 'store'])->name('discounts.store');
+            Route::put('discounts/{discount}', [AdminDiscountController::class, 'update'])->name('discounts.update');
+            Route::delete('discounts/{discount}', [AdminDiscountController::class, 'destroy'])->name('discounts.destroy');
+        });
+
         Route::middleware('admin.api:read-orders')->group(function (): void {
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/routes/api.php

type: update

unified_diff:
@@ -4,2 +4,3 @@
 use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
+use App\Http\Controllers\Api\Admin\V1\DiscountController as AdminDiscountController;
 use App\Http\Controllers\Api\Admin\V1\OrderController as AdminOrderController;
@@ -75,2 +76,12 @@

+        Route::middleware('admin.api:read-discounts')->group(function (): void {
+            Route::get('discounts', [AdminDiscountController::class, 'index'])->name('discounts.index');
+        });
+
+        Route::middleware('admin.api:write-discounts')->group(function (): void {
+            Route::post('discounts', [AdminDiscountController::class, 'store'])->name('discounts.store');
+            Route::put('discounts/{discount}', [AdminDiscountController::class, 'update'])->name('discounts.update');
+            Route::delete('discounts/{discount}', [AdminDiscountController::class, 'destroy'])->name('discounts.destroy');
+        });
+
         Route::middleware('admin.api:read-orders')->group(function (): void {


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


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/DiscountController.php
@@
 use App\Models\Store;
 use Carbon\CarbonInterface;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Support\Carbon;
 use Illuminate\Support\Str;
@@
-            $startsAt = now()->parse($startsAt);
+            $startsAt = Carbon::parse($startsAt);
         }

-        $endsAt = now()->parse($validated['ends_at']);
+        $endsAt = Carbon::parse($validated['ends_at']);
*** End Patch


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

unified_diff:
@@ -13,2 +13,3 @@
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Support\Carbon;
 use Illuminate\Support\Str;
@@ -190,6 +191,6 @@
         if (! $startsAt instanceof CarbonInterface) {
-            $startsAt = now()->parse($startsAt);
+            $startsAt = Carbon::parse($startsAt);
         }

-        $endsAt = now()->parse($validated['ends_at']);
+        $endsAt = Carbon::parse($validated['ends_at']);


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


cmd: sed -n '1,80p' tests/Feature/Api/AdminDiscountApiTest.php


command: sed -n '1,80p' tests/Feature/Api/AdminDiscountApiTest.php

AdminDiscountApiTest.php

type: read

aggregated_output:
<?php

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});


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

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});


status: completed

input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminDiscountApiTest.php
*** End Patch


stdout:
Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminDiscountApiTest.php

content:
<?php

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});


output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminDiscountApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminDiscountApiTest.php
+<?php
+
+use App\Models\Collection;
+use App\Models\Discount;
+use App\Models\Product;
+use App\Models\Store;
+use App\Models\User;
+use App\Services\WebhookService;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminDiscountApiStore(): Store
+{
+    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+}
+
+function adminDiscountApiUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+/**
+ * @param  list<string>  $abilities
+ * @return array{token: \App\Models\OauthToken, plain_text: string}
+ */
+function adminDiscountApiToken(Store $store, array $abilities): array
+{
+    return app(WebhookService::class)->createApiToken($store, 'Discount integration', $abilities);
+}
+
+test('admin discount api lists creates updates and deletes discounts', function (): void {
+    $store = adminDiscountApiStore();
+    $product = Product::factory()->withDefaultVariant()->create(['store_id' => $store->getKey()]);
+    $collection = Collection::factory()->create(['store_id' => $store->getKey()]);
+    $user = adminDiscountApiUser();
+
+    $this->actingAs($user)
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code&status=active")
+        ->assertOk();
+
+    $createResponse = $this->actingAs($user)
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [
+            'type' => 'code',
+            'code' => 'api20',
+            'value_type' => 'percent',
+            'value_amount' => 20,
+            'starts_at' => now()->subHour()->toIso8601String(),
+            'ends_at' => now()->addMonth()->toIso8601String(),
+            'usage_limit' => 50,
+            'rules_json' => [
+                'minimum_purchase_amount' => 5000,
+                'applicable_product_ids' => [$product->getKey()],
+                'applicable_collection_ids' => [$collection->getKey()],
+                'customer_eligibility' => 'all',
+                'once_per_customer' => true,
+            ],
+        ])
+        ->assertCreated()
+        ->assertJsonPath('data.code', 'API20')
+        ->assertJsonPath('data.rules_json.min_purchase_amount', 5000)
+        ->assertJsonPath('data.rules_json.one_per_customer', true);
+
+    $discount = Discount::withoutGlobalScopes()->findOrFail($createResponse->json('data.id'));
+
+    expect($discount->rules_json['applicable_product_ids'])->toBe([$product->getKey()])
+        ->and($discount->rules_json['applicable_collection_ids'])->toBe([$collection->getKey()]);
+
+    $this->actingAs($user)
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}", [
+            'value_type' => 'fixed',
+            'value_amount' => 750,
+            'usage_limit' => 25,
+            'status' => 'disabled',
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.value_type', 'fixed')
+        ->assertJsonPath('data.value_amount', 750)
+        ->assertJsonPath('data.status', 'disabled');
+
+    $this->actingAs($user)
+        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
+        ->assertOk()
+        ->assertJsonPath('message', 'Discount deleted');
+
+    expect(Discount::withoutGlobalScopes()->whereKey($discount->getKey())->exists())->toBeFalse();
+});
+
+test('admin discount api enforces token abilities and store scope', function (): void {
+    $store = adminDiscountApiStore();
+    $otherStore = Store::factory()->create();
+    $discount = Discount::factory()->create([
+        'store_id' => $store->getKey(),
+        'code' => 'TOKEN25',
+    ]);
+    $readToken = adminDiscountApiToken($store, ['read-discounts']);
+    $writeToken = adminDiscountApiToken($store, ['write-discounts']);
+    $otherStoreToken = adminDiscountApiToken($otherStore, ['read-discounts']);
+
+    $this->withToken($readToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code")
+        ->assertOk()
+        ->assertJsonFragment(['code' => 'TOKEN25']);
+
+    $this->withToken($readToken['plain_text'])
+        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
+        ->assertForbidden();
+
+    $this->withToken($otherStoreToken['plain_text'])
+        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts")
+        ->assertForbidden();
+
+    $this->withToken($writeToken['plain_text'])
+        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
+        ->assertOk();
+});
+
+test('admin discount api validates code dates and scoped rule resources', function (): void {
+    $store = adminDiscountApiStore();
+    $existing = Discount::factory()->create([
+        'store_id' => $store->getKey(),
+        'code' => 'EXISTING',
+    ]);
+    $otherStoreProduct = Product::factory()
+        ->withDefaultVariant()
+        ->create(['store_id' => Store::factory()->create()->getKey()]);
+
+    $this->actingAs(adminDiscountApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [
+            'type' => 'code',
+            'code' => Str::lower($existing->code),
+            'value_type' => 'percent',
+            'value_amount' => 101,
+            'starts_at' => now()->addDay()->toIso8601String(),
+            'ends_at' => now()->subDay()->toIso8601String(),
+            'rules_json' => [
+                'applicable_product_ids' => [$otherStoreProduct->getKey()],
+            ],
+        ])
+        ->assertUnprocessable()
+        ->assertJsonValidationErrors(['code', 'value_amount', 'ends_at', 'rules_json.applicable_product_ids.0']);
+});
*** End Patch


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminDiscountApiTest.php

content:
<?php

use App\Models\Collection;
use App\Models\Discount;
use App\Models\Product;
use App\Models\Store;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminDiscountApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

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

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

test('admin discount api lists creates updates and deletes discounts', function (): void {
    $store = adminDiscountApiStore();
    $product = Product::factory()->withDefaultVariant()->create(['store_id' => $store->getKey()]);
    $collection = Collection::factory()->create(['store_id' => $store->getKey()]);
    $user = adminDiscountApiUser();

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code&status=active")
        ->assertOk();

    $createResponse = $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [
            'type' => 'code',
            'code' => 'api20',
            'value_type' => 'percent',
            'value_amount' => 20,
            'starts_at' => now()->subHour()->toIso8601String(),
            'ends_at' => now()->addMonth()->toIso8601String(),
            'usage_limit' => 50,
            'rules_json' => [
                'minimum_purchase_amount' => 5000,
                'applicable_product_ids' => [$product->getKey()],
                'applicable_collection_ids' => [$collection->getKey()],
                'customer_eligibility' => 'all',
                'once_per_customer' => true,
            ],
        ])
        ->assertCreated()
        ->assertJsonPath('data.code', 'API20')
        ->assertJsonPath('data.rules_json.min_purchase_amount', 5000)
        ->assertJsonPath('data.rules_json.one_per_customer', true);

    $discount = Discount::withoutGlobalScopes()->findOrFail($createResponse->json('data.id'));

    expect($discount->rules_json['applicable_product_ids'])->toBe([$product->getKey()])
        ->and($discount->rules_json['applicable_collection_ids'])->toBe([$collection->getKey()]);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}", [
            'value_type' => 'fixed',
            'value_amount' => 750,
            'usage_limit' => 25,
            'status' => 'disabled',
        ])
        ->assertOk()
        ->assertJsonPath('data.value_type', 'fixed')
        ->assertJsonPath('data.value_amount', 750)
        ->assertJsonPath('data.status', 'disabled');

    $this->actingAs($user)
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertOk()
        ->assertJsonPath('message', 'Discount deleted');

    expect(Discount::withoutGlobalScopes()->whereKey($discount->getKey())->exists())->toBeFalse();
});

test('admin discount api enforces token abilities and store scope', function (): void {
    $store = adminDiscountApiStore();
    $otherStore = Store::factory()->create();
    $discount = Discount::factory()->create([
        'store_id' => $store->getKey(),
        'code' => 'TOKEN25',
    ]);
    $readToken = adminDiscountApiToken($store, ['read-discounts']);
    $writeToken = adminDiscountApiToken($store, ['write-discounts']);
    $otherStoreToken = adminDiscountApiToken($otherStore, ['read-discounts']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code")
        ->assertOk()
        ->assertJsonFragment(['code' => 'TOKEN25']);

    $this->withToken($readToken['plain_text'])
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertForbidden();

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

    $this->withToken($writeToken['plain_text'])
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertOk();
});

test('admin discount api validates code dates and scoped rule resources', function (): void {
    $store = adminDiscountApiStore();
    $existing = Discount::factory()->create([
        'store_id' => $store->getKey(),
        'code' => 'EXISTING',
    ]);
    $otherStoreProduct = Product::factory()
        ->withDefaultVariant()
        ->create(['store_id' => Store::factory()->create()->getKey()]);

    $this->actingAs(adminDiscountApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [
            'type' => 'code',
            'code' => Str::lower($existing->code),
            'value_type' => 'percent',
            'value_amount' => 101,
            'starts_at' => now()->addDay()->toIso8601String(),
            'ends_at' => now()->subDay()->toIso8601String(),
            'rules_json' => [
                'applicable_product_ids' => [$otherStoreProduct->getKey()],
            ],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['code', 'value_amount', 'ends_at', 'rules_json.applicable_product_ids.0']);
});


output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminDiscountApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminDiscountApiTest.php
@@
 use App\Services\WebhookService;
 use Database\Seeders\DatabaseSeeder;
 use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/tests/Feature/Api/AdminDiscountApiTest.php

unified_diff:
@@ -10,2 +10,3 @@
 use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;


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


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/DiscountController.php
@@
-use Carbon\CarbonInterface;
 use Illuminate\Database\Eloquent\Builder;
@@
-            'starts_at' => ['sometimes', 'nullable', 'date'],
-            'ends_at' => ['sometimes', 'nullable', 'date'],
+            'starts_at' => ['sometimes', 'nullable', 'date'],
+            'ends_at' => [
+                'sometimes',
+                'nullable',
+                'date',
+                function (string $attribute, mixed $value, \Closure $fail) use ($request, $discount): void {
+                    if ($value === null || $value === '') {
+                        return;
+                    }
+
+                    $startsAt = $request->input('starts_at', $discount?->starts_at ?? now());
+
+                    if (Carbon::parse($value)->lte(Carbon::parse($startsAt))) {
+                        $fail(__('The end date must be after the start date.'));
+                    }
+                },
+            ],
@@
-        $this->validateDates($validated, $discount);
-
         return $validated;
     }
@@
-    /**
-     * @param  array<string, mixed>  $validated
-     */
-    private function validateDates(array $validated, ?Discount $discount): void
-    {
-        if (! array_key_exists('ends_at', $validated) || $validated['ends_at'] === null) {
-            return;
-        }
-
-        $startsAt = data_get($validated, 'starts_at', $discount?->starts_at ?? now());
-
-        if (! $startsAt instanceof CarbonInterface) {
-            $startsAt = Carbon::parse($startsAt);
-        }
-
-        $endsAt = Carbon::parse($validated['ends_at']);
-
-        if ($endsAt->lte($startsAt)) {
-            throw ValidationException::withMessages([
-                'ends_at' => __('The end date must be after the start date.'),
-            ]);
-        }
-    }
-
*** End Patch


unified_diff:
@@ -8,3 +8,2 @@
 use App\Models\Store;
-use Carbon\CarbonInterface;
 use Illuminate\Database\Eloquent\Builder;
@@ -145,3 +144,18 @@
             'starts_at' => ['sometimes', 'nullable', 'date'],
-            'ends_at' => ['sometimes', 'nullable', 'date'],
+            'ends_at' => [
+                'sometimes',
+                'nullable',
+                'date',
+                function (string $attribute, mixed $value, \Closure $fail) use ($request, $discount): void {
+                    if ($value === null || $value === '') {
+                        return;
+                    }
+
+                    $startsAt = $request->input('starts_at', $discount?->starts_at ?? now());
+
+                    if (Carbon::parse($value)->lte(Carbon::parse($startsAt))) {
+                        $fail(__('The end date must be after the start date.'));
+                    }
+                },
+            ],
             'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'],
@@ -159,4 +173,2 @@
         ]);
-
-        $this->validateDates($validated, $discount);

@@ -177,26 +189,2 @@
         };
-    }
-
-    /**
-     * @param  array<string, mixed>  $validated
-     */
-    private function validateDates(array $validated, ?Discount $discount): void
-    {
-        if (! array_key_exists('ends_at', $validated) || $validated['ends_at'] === null) {
-            return;
-        }
-
-        $startsAt = data_get($validated, 'starts_at', $discount?->starts_at ?? now());
-
-        if (! $startsAt instanceof CarbonInterface) {
-            $startsAt = Carbon::parse($startsAt);
-        }
-
-        $endsAt = Carbon::parse($validated['ends_at']);
-
-        if ($endsAt->lte($startsAt)) {
-            throw ValidationException::withMessages([
-                'ends_at' => __('The end date must be after the start date.'),
-            ]);
-        }
     }


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


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/V1/DiscountController.php
@@
 use Illuminate\Support\Carbon;
 use Illuminate\Support\Str;
 use Illuminate\Validation\Rule;
-use Illuminate\Validation\ValidationException;
*** End Patch


unified_diff:
@@ -15,3 +15,2 @@
 use Illuminate\Validation\Rule;
-use Illuminate\Validation\ValidationException;


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


message: The discount API is implemented with case-insensitive code uniqueness, date validation, scoped product/collection rule validation, and token ability checks. I’m formatting and running the discount API file now.

phase: commentary


type: message

role: assistant

text: The discount API is implemented with case-insensitive code uniqueness, date validation, scoped product/collection rule validation, and token ability checks. I’m formatting and running the discount API file now.


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


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


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

type: unknown

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

result: pass


output:
Chunk ID: e72ddf
Wall time: 1.4120 seconds
Process exited with code 0
Original token count: 16
Output:

  ...

  Tests:    3 passed (25 assertions)
  Duration: 1.31s


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

aggregated_output:
  ...

  Tests:    3 passed (25 assertions)
  Duration: 1.31s


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


output:
Chunk ID: 8960e2
Wall time: 10.2129 seconds
Process exited with code 0
Original token count: 24
Output:

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

  Tests:    31 passed (243 assertions)
  Duration: 10.14s


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


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


output:
Chunk ID: c8ce65
Wall time: 0.0430 seconds
Process exited with code 0
Original token count: 345
Output:

  GET|HEAD  api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  POST      api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  PUT       api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  DELETE    api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  GET|HEAD  api/admin/v1/stores/{store}/customers api.admin.v1.customers.inde…
  GET|HEAD  api/admin/v1/stores/{store}/customers/{customer} api.admin.v1.cus…
  GET|HEAD  api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.inde…
  POST      api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.stor…
  PUT       api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  DELETE    api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  GET|HEAD  api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › Ap…
  GET|HEAD  api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.sh…
  POST      api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admin…
  POST      api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.o…
  GET|HEAD  api/admin/v1/stores/{store}/products api.admin.v1.products.index …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…

                                                           Showing [16] routes


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

aggregated_output:
  GET|HEAD  api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  POST      api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  PUT       api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  DELETE    api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  GET|HEAD  api/admin/v1/stores/{store}/customers api.admin.v1.customers.inde…
  GET|HEAD  api/admin/v1/stores/{store}/customers/{customer} api.admin.v1.cus…
  GET|HEAD  api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.inde…
  POST      api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.stor…
  PUT       api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  DELETE    api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  GET|HEAD  api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › Ap…
  GET|HEAD  api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.sh…
  POST      api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admin…
  POST      api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.o…
  GET|HEAD  api/admin/v1/stores/{store}/products api.admin.v1.products.index …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…

                                                           Showing [16] routes


cmd: rg -n "Admin REST API now includes|admin.api middleware|larger admin REST surfaces|product/customer/collection/order|admin collection API" specs/progress.md


command: rg -n "Admin REST API now includes|admin.api middleware|larger admin REST surfaces|product/customer/collection/order|admin collection API" specs/progress.md

type: search

cmd: rg -n 'Admin REST API now includes|admin.api middleware|larger admin REST surfaces|product/customer/collection/order|admin collection API' specs/progress.md

query: Admin REST API now includes|admin.api middleware|larger admin REST surfaces|product/customer/collection/order|admin collection API

aggregated_output:
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`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining non-order admin REST surfaces are still missing. |
334:- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 API resource controller, route group middleware, scoped exists validation, and JSON API testing docs plus Pest 4 docs before the admin collection API changes.
336:- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin collection API changes.
337:- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCollectionApiTest.php` passed after the admin collection API changes: 3 tests, 24 assertions.
338:- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin collection API changes: 28 tests, 218 assertions.
340:- 2026-05-04: `php artisan test --compact` passed after the admin collection API changes: 215 tests, 1175 assertions.
405:- Non-order admin REST write endpoints beyond collections and the larger admin REST surfaces for discounts, settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
411:Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.


output:
Chunk ID: a429c2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 991
Output:
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`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining non-order admin REST surfaces are still missing. |
334:- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 API resource controller, route group middleware, scoped exists validation, and JSON API testing docs plus Pest 4 docs before the admin collection API changes.
336:- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin collection API changes.
337:- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCollectionApiTest.php` passed after the admin collection API changes: 3 tests, 24 assertions.
338:- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin collection API changes: 28 tests, 218 assertions.
340:- 2026-05-04: `php artisan test --compact` passed after the admin collection API changes: 215 tests, 1175 assertions.
405:- Non-order admin REST write endpoints beyond collections and the larger admin REST surfaces for discounts, settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
411:Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
-| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining non-order admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin collection API changes: 28 tests, 218 assertions.
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 12 admin API routes, including collection list/create/update/delete.
 - 2026-05-04: `php artisan test --compact` passed after the admin collection API changes: 215 tests, 1175 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 validation, date validation, prohibited/unique update-field validation, JSON API testing, and Pest 4 docs before the admin discount API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/DiscountController --no-interaction`, `php artisan make:resource Admin/V1/DiscountResource --no-interaction`, and `php artisan make:test Api/AdminDiscountApiTest --pest --no-interaction` created the discount API controller/resource and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin discount API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminDiscountApiTest.php` passed after the admin discount API changes: 3 tests, 25 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin discount API changes: 31 tests, 243 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 16 admin API routes, including discount list/create/update/delete.
@@
-- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, and order fulfillment/refund mutations.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, and order fulfillment/refund mutations.
@@
-- Non-order admin REST write endpoints beyond collections and the larger admin REST surfaces for discounts, settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST surfaces for settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
@@
-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
*** End Patch


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

unified_diff:
@@ -29,3 +29,3 @@
 | Database schema | `specs/01-DATABASE-SCHEMA.md` | partial | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, and Phase 7 search/analytics/apps/webhooks tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, and webhook_deliveries. |
-| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining non-order admin REST surfaces are still missing. |
+| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. Write endpoints for remaining admin REST surfaces are still missing. |
 | Admin UI | `specs/03-ADMIN-UI.md` | partial | Flux/Livewire admin shell now includes dashboard KPI/date-range reporting, analytics reporting with filters/export, catalog/product/collection/inventory management, product option matrix generation, product media upload/alt text/reorder/delete controls, order/customer/discount management, page index/form, nested navigation menu editor with drag sorting and parent selection, theme grid, theme settings editor with iframe preview, theme file list/editor, general settings, domain management, shipping zone/rate management, tax settings, checkout policy settings, notification settings, search settings/reindexing, installed app list/detail, and developer token/webhook management with auth protection and store scoping. |
@@ -340,2 +340,8 @@
 - 2026-05-04: `php artisan test --compact` passed after the admin collection API changes: 215 tests, 1175 assertions.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 validation, date validation, prohibited/unique update-field validation, JSON API testing, and Pest 4 docs before the admin discount API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/DiscountController --no-interaction`, `php artisan make:resource Admin/V1/DiscountResource --no-interaction`, and `php artisan make:test Api/AdminDiscountApiTest --pest --no-interaction` created the discount API controller/resource and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin discount API changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminDiscountApiTest.php` passed after the admin discount API changes: 3 tests, 25 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin discount API changes: 31 tests, 243 assertions.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 16 admin API routes, including discount list/create/update/delete.

@@ -373,3 +379,3 @@
 - Storefront order lookup uses a deterministic HMAC access token derived from store/order identity so confirmation links can be token-gated without adding a new runtime column.
-- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, and order fulfillment/refund mutations.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders, collection CRUD, discount CRUD, and order fulfillment/refund mutations.
 - Admin dashboard sales and top-product metrics are derived from paid or partially refunded orders; the dedicated analytics page now reads analytics event and daily aggregate tables.
@@ -404,3 +410,3 @@
 - `php artisan route:list --except-vendor` hides Livewire full-page routes because their controller is vendor-provided; path-filtered route-list commands are used as evidence for those routes.
-- Non-order admin REST write endpoints beyond collections and the larger admin REST surfaces for discounts, settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Admin REST surfaces for settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
 - Full automated browser suites from Spec 08 are still incomplete beyond the initial Pest browser smoke coverage for storefront, admin, and mobile rendering.
@@ -410,2 +416,2 @@

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/collection/discount/order API surfaces, deferred OAuth/app route stubs, initial automated browser smoke coverage, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, full browser-suite, and database CHECK constraint gaps tracked above.


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


message: Discount API routes and focused API tests pass. I’m running the full suite now and will update the progress count afterward.


text: Discount API routes and focused API tests pass. I’m running the full suite now and will update the progress count afterward.


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

cmd: git diff --check


output:
Chunk ID: 245fba
Wall time: 30.0021 seconds
Process running with session ID 33013
Original token count: 25
Output:

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


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


output:
Chunk ID: 27e2b4
Wall time: 17.5216 seconds
Process exited with code 0
Original token count: 48
Output:
...........................................................
  ..................................................................

  Tests:    218 passed (1200 assertions)
  Duration: 54.07s


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

cmd: php artisan test --compact


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminDiscountApiTest.php` passed after the admin discount API changes: 3 tests, 25 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin discount API changes: 31 tests, 243 assertions.
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 16 admin API routes, including discount list/create/update/delete.
+- 2026-05-04: `php artisan test --compact` passed after the admin discount API changes: 218 tests, 1200 assertions.
*** End Patch


unified_diff:
@@ -346,2 +346,3 @@
 - 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 16 admin API routes, including discount list/create/update/delete.
+- 2026-05-04: `php artisan test --compact` passed after the admin discount API changes: 218 tests, 1200 assertions.


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


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

cmd: git status --short

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


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

cmd: git diff --stat

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


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


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


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


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

cmd: git clean -f .playwright-mcp

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


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


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


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


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


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

aggregated_output:
<?php

use App\Models\Collection;
use App\Models\Discount;
use App\Models\Product;
use App\Models\Store;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminDiscountApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

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

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

test('admin discount api lists creates updates and deletes discounts', function (): void {
    $store = adminDiscountApiStore();
    $product = Product::factory()->withDefaultVariant()->create(['store_id' => $store->getKey()]);
    $collection = Collection::factory()->create(['store_id' => $store->getKey()]);
    $user = adminDiscountApiUser();

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code&status=active")
        ->assertOk();

    $createResponse = $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [
            'type' => 'code',
            'code' => 'api20',
            'value_type' => 'percent',
            'value_amount' => 20,
            'starts_at' => now()->subHour()->toIso8601String(),
            'ends_at' => now()->addMonth()->toIso8601String(),
            'usage_limit' => 50,
            'rules_json' => [
                'minimum_purchase_amount' => 5000,
                'applicable_product_ids' => [$product->getKey()],
                'applicable_collection_ids' => [$collection->getKey()],
                'customer_eligibility' => 'all',
                'once_per_customer' => true,
            ],
        ])
        ->assertCreated()
        ->assertJsonPath('data.code', 'API20')
        ->assertJsonPath('data.rules_json.min_purchase_amount', 5000)
        ->assertJsonPath('data.rules_json.one_per_customer', true);

    $discount = Discount::withoutGlobalScopes()->findOrFail($createResponse->json('data.id'));

    expect($discount->rules_json['applicable_product_ids'])->toBe([$product->getKey()])
        ->and($discount->rules_json['applicable_collection_ids'])->toBe([$collection->getKey()]);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}", [
            'value_type' => 'fixed',
            'value_amount' => 750,
            'usage_limit' => 25,
            'status' => 'disabled',
        ])
        ->assertOk()
        ->assertJsonPath('data.value_type', 'fixed')
        ->assertJsonPath('data.value_amount', 750)
        ->assertJsonPath('data.status', 'disabled');

    $this->actingAs($user)
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertOk()
        ->assertJsonPath('message', 'Discount deleted');

    expect(Discount::withoutGlobalScopes()->whereKey($discount->getKey())->exists())->toBeFalse();
});

test('admin discount api enforces token abilities and store scope', function (): void {
    $store = adminDiscountApiStore();
    $otherStore = Store::factory()->create();
    $discount = Discount::factory()->create([
        'store_id' => $store->getKey(),
        'code' => 'TOKEN25',
    ]);
    $readToken = adminDiscountApiToken($store, ['read-discounts']);
    $writeToken = adminDiscountApiToken($store, ['write-discounts']);
    $otherStoreToken = adminDiscountApiToken($otherStore, ['read-discounts']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/discounts?type=code")
        ->assertOk()
        ->assertJsonFragment(['code' => 'TOKEN25']);

    $this->withToken($readToken['plain_text'])
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertForbidden();

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

    $this->withToken($writeToken['plain_text'])
        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/discounts/{$discount->getKey()}")
        ->assertOk();
});

test('admin discount api validates code dates and scoped rule resources', function (): void {
    $store = adminDiscountApiStore();
    $existing = Discount::factory()->create([
        'store_id' => $store->getKey(),
        'code' => 'EXISTING',
    ]);
    $otherStoreProduct = Product::factory()
        ->withDefaultVariant()
        ->create(['store_id' => Store::factory()->create()->getKey()]);

    $this->actingAs(adminDiscountApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/discounts", [
            'type' => 'code',
            'code' => Str::lower($existing->code),
            'value_type' => 'percent',
            'value_amount' => 101,
            'starts_at' => now()->addDay()->toIso8601String(),
            'ends_at' => now()->subDay()->toIso8601String(),
            'rules_json' => [
                'applicable_product_ids' => [$otherStoreProduct->getKey()],
            ],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['code', 'value_amount', 'ends_at', 'rules_json.applicable_product_ids.0']);
});