command: rg -n "raw_json_encrypted|raw_json" app tests database | head -100; sed -n '1,140p' app/Services/PricingEngine.php; sed -n '1,160p' app/Services/TaxCalculator.php; sed -n '1,180p' app/Services/ShippingCalculator.php; sed -n '1,160p' app/Services/AnalyticsService.php; sed -n '1,180p' app/Jobs/AggregateAnalytics.php; sed -n '1,130p' app/Http/Controllers/Api/StorefrontAnalyticsController.php

cmd: rg -n 'raw_json_encrypted|raw_json' app tests database

path: app

StorefrontAnalyticsController.php

stdout:
database/factories/PaymentFactory.php:32:            'raw_json_encrypted' => null,
app/Services/PaymentService.php:51:            Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'currency' => $order->currency, 'raw_json_encrypted' => json_encode(['reference' => $result->reference, 'message' => $result->message])]);
database/migrations/2026_08_20_000000_create_shop_schema.php:441:            $table->text('raw_json_encrypted')->nullable();
app/Models/Payment.php:12:    protected $fillable = ['order_id', 'provider', 'provider_payment_id', 'method', 'status', 'amount', 'currency', 'raw_json_encrypted'];
app/Models/Payment.php:14:    protected $hidden = ['raw_json_encrypted'];
app/Models/Payment.php:18:        return ['method' => PaymentMethod::class, 'status' => PaymentStatus::class, 'raw_json_encrypted' => 'encrypted'];
<?php

namespace App\Services;

use App\Models\Checkout;
use App\Models\Discount;
use App\Models\TaxSettings;
use App\ValueObjects\PricingResult;

class PricingEngine
{
    public function __construct(private readonly DiscountService $discounts, private readonly ShippingCalculator $shipping, private readonly TaxCalculator $taxes) {}

    public function calculate(Checkout $checkout): PricingResult
    {
        $checkout->load(['cart.lines.variant.product.collections', 'shippingRate']);
        $cart = $checkout->cart;
        $lines = $cart->lines;
        $subtotal = (int) $lines->sum(fn ($line): int => $line->unit_price_amount * $line->quantity);
        $discountAmount = 0;
        $freeShipping = false;

        foreach ($lines as $line) {
            $line->updateQuietly(['line_discount_amount' => 0, 'line_total_amount' => $line->line_subtotal_amount]);
        }

        if ($checkout->discount_code !== null) {
            $discount = Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->first();

            if ($discount?->isAvailable()) {
                $result = $this->discounts->calculate($discount, $subtotal, $lines->map(fn ($line): array => [
                    'line_id' => $line->id,
                    'amount' => $line->unit_price_amount * $line->quantity,
                    'product_id' => $line->variant->product_id,
                    'collection_ids' => $line->variant->product->collections->modelKeys(),
                ])->all());
                $discountAmount = $result->amount;
                $freeShipping = $result->freeShipping;

                foreach ($lines as $line) {
                    $lineDiscount = $result->allocations[$line->id] ?? 0;
                    $line->updateQuietly(['line_discount_amount' => $lineDiscount, 'line_total_amount' => max(0, $line->line_subtotal_amount - $lineDiscount)]);
                }
            }
        }

        $shippingAmount = $checkout->shippingRate !== null && ! $freeShipping ? $this->shipping->calculate($checkout->shippingRate, $cart) : 0;
        $taxSettings = TaxSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $checkout->store_id], ['default_rate_basis_points' => 0]);
        $taxLines = [];

        foreach ($lines as $line) {
            $lineAmount = max(0, $line->line_subtotal_amount - $line->line_discount_amount);
            $taxLines = [...$taxLines, ...$this->taxes->calculate($lineAmount, $taxSettings, $checkout->shipping_address_json ?? [])->lines];
        }

        if ($shippingAmount > 0) {
            $taxLines = [...$taxLines, ...$this->taxes->calculate($shippingAmount, $taxSettings, $checkout->shipping_address_json ?? [])->lines];
        }

        $taxTotal = (int) collect($taxLines)->sum('amount');
        $discountedSubtotal = max(0, $subtotal - $discountAmount);
        $pricesIncludeTax = (bool) $taxSettings->prices_include_tax || $taxSettings->mode === 'inclusive';
        $total = max(0, $discountedSubtotal + $shippingAmount + ($pricesIncludeTax ? 0 : $taxTotal));
        $result = new PricingResult($subtotal, $discountAmount, $shippingAmount, $taxLines, $taxTotal, $total, $cart->currency);
        $checkout->update(['totals_json' => $result->toArray()]);

        return $result;
    }
}
<?php

namespace App\Services;

use App\Contracts\TaxProvider;
use App\Models\TaxSettings;
use App\ValueObjects\TaxCalculationRequest;
use App\ValueObjects\TaxResult;

class TaxCalculator
{
    public function __construct(private readonly ?TaxProvider $provider = null) {}

    public function calculate(int $amount, TaxSettings $settings, array $address): TaxResult
    {
        return ($this->provider ?? new \App\Services\Tax\ManualTaxProvider)->calculate(new TaxCalculationRequest([['amount' => $amount]], 0, $address, $settings));
    }

    public function extractInclusive(int $grossAmount, int $rateBasisPoints): int
    {
        return $rateBasisPoints > 0 ? intdiv($grossAmount * $rateBasisPoints, 10000 + $rateBasisPoints) : 0;
    }

    public function addExclusive(int $netAmount, int $rateBasisPoints): int
    {
        return intdiv($netAmount * $rateBasisPoints, 10000);
    }
}
<?php

namespace App\Services;

use App\Models\Cart;
use App\Models\ShippingRate;
use App\Models\Store;
use Illuminate\Support\Collection;

class ShippingCalculator
{
    public function getAvailableRates(Store $store, array $address): Collection
    {
        $country = strtoupper((string) ($address['country_code'] ?? ''));
        $region = strtoupper((string) ($address['province_code'] ?? ''));

        $rates = ShippingRate::query()->where('is_active', true)->whereHas('zone', fn ($query) => $query->where('store_id', $store->getKey()))->with('zone')->get();

        $matching = $rates->filter(function (ShippingRate $rate) use ($country, $region): bool {
            $countries = array_map('strtoupper', $rate->zone->countries_json ?? []);
            $regions = array_map('strtoupper', $rate->zone->regions_json ?? []);

            return ($region !== '' && in_array($region, $regions, true))
                || ($country !== '' && in_array($country, $countries, true))
                || ($countries === [] && $regions === []);
        });

        $specificity = $matching->groupBy(function (ShippingRate $rate) use ($country, $region): int {
            $countries = array_map('strtoupper', $rate->zone->countries_json ?? []);
            $regions = array_map('strtoupper', $rate->zone->regions_json ?? []);

            return ($region !== '' && in_array($region, $regions, true)) ? 2 : (($country !== '' && in_array($country, $countries, true)) ? 1 : 0);
        });

        return $specificity->sortKeysDesc()->first() ?? collect();
    }

    public function calculate(ShippingRate $rate, Cart $cart): int
    {
        $lines = $cart->load('lines.variant')->lines;
        $lines = $lines->filter(fn ($line): bool => (bool) $line->variant->requires_shipping);
        $weight = (int) $lines->sum(fn ($line): int => $line->quantity * ($line->variant->weight_g ?? $line->variant->weight_grams));
        $subtotal = (int) $lines->sum('line_total_amount');
        $config = $rate->config_json ?? [];

        return match ($rate->type) {
            'weight' => $this->rangeAmount($config['ranges'] ?? [], $weight, $rate->price_amount),
            'price' => $this->rangeAmount($config['ranges'] ?? [], $subtotal, $rate->price_amount),
            default => $rate->price_amount,
        };
    }

    private function rangeAmount(array $ranges, int $value, int $fallback): int
    {
        foreach ($ranges as $range) {
            if ($value >= (int) ($range['min_g'] ?? $range['min_amount'] ?? 0) && $value <= (int) ($range['max_g'] ?? $range['max_amount'] ?? PHP_INT_MAX)) {
                return (int) ($range['amount'] ?? $fallback);
            }
        }

        return 0;
    }
}
<?php

namespace App\Services;

use App\Models\AnalyticsEvent;
use App\Models\Store;
use Illuminate\Support\Collection;

class AnalyticsService
{
    /** @var array<int, string> */
    private array $eventTypes = ['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search'];

    public function track(Store $store, string $type, array $properties = [], ?string $sessionId = null, ?int $customerId = null, ?string $clientEventId = null, ?\DateTimeInterface $occurredAt = null): AnalyticsEvent
    {
        if (! in_array($type, $this->eventTypes, true)) {
            throw new \InvalidArgumentException('Unsupported analytics event type.');
        }

        if ($clientEventId !== null) {
            $existing = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->getKey())->where('client_event_id', $clientEventId)->first();

            if ($existing !== null) {
                return $existing;
            }
        }

        return AnalyticsEvent::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'type' => $type, 'session_id' => $sessionId, 'customer_id' => $customerId, 'client_event_id' => $clientEventId, 'payload' => $properties, 'properties_json' => $properties, 'occurred_at' => $occurredAt ?? now()]);
    }

    public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection
    {
        return \App\Models\AnalyticsDaily::withoutGlobalScopes()->where('store_id', $store->getKey())->whereBetween('date', [$startDate, $endDate])->orderBy('date')->get();
    }
}
<?php

namespace App\Jobs;

use App\Models\AnalyticsDaily;
use App\Models\AnalyticsEvent;
use App\Models\Order;
use App\Models\Store;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class AggregateAnalytics implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function __construct(public ?Store $store = null, public ?string $date = null) {}

    public function handle(): void
    {
        $date = CarbonImmutable::parse($this->date ?? now()->subDay()->toDateString());
        $stores = $this->store === null ? Store::query()->get() : collect([$this->store]);

        foreach ($stores as $store) {
            $events = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('created_at', $date)->get();
            $orders = Order::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('placed_at', $date)->whereIn('financial_status', ['paid', 'partially_refunded'])->get();
            $revenue = (int) $orders->sum('total_amount');

            AnalyticsDaily::withoutGlobalScopes()->newQuery()->updateOrInsert(
                ['store_id' => $store->getKey(), 'date' => $date->toDateString()],
                [
                    'orders_count' => $orders->count(),
                    'revenue_amount' => $revenue,
                    'aov_amount' => $orders->count() > 0 ? intdiv($revenue, $orders->count()) : 0,
                    'visits_count' => $events->where('type', 'page_view')->count(),
                    'add_to_cart_count' => $events->where('type', 'add_to_cart')->count(),
                    'checkout_started_count' => $events->where('type', 'checkout_started')->count(),
                    'checkout_completed_count' => $events->where('type', 'checkout_completed')->count(),
                ],
            );
        }
    }
}
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\AnalyticsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StorefrontAnalyticsController extends Controller
{
    public function __construct(private readonly AnalyticsService $analytics) {}

    public function store(Request $request): JsonResponse
    {
        $data = $request->validate([
            'events' => ['sometimes', 'array', 'min:1', 'max:100'],
            'events.*.type' => ['required_with:events', 'string'],
            'events.*.properties' => ['nullable', 'array'],
            'events.*.session_id' => ['nullable', 'string', 'max:255'],
            'events.*.client_event_id' => ['nullable', 'string', 'max:255'],
            'events.*.occurred_at' => ['nullable', 'date'],
            'type' => ['required_without:events', 'string'],
            'properties' => ['nullable', 'array'],
            'session_id' => ['nullable', 'string', 'max:255'],
            'client_event_id' => ['nullable', 'string', 'max:255'],
            'occurred_at' => ['nullable', 'date'],
        ]);
        $events = $data['events'] ?? [$data];
        $stored = [];
        foreach ($events as $event) {
            $stored[] = $this->analytics->track(app('current_store'), $event['type'], $event['properties'] ?? [], $event['session_id'] ?? ($request->hasSession() ? $request->session()->getId() : null), $request->user('customer')?->getKey(), $event['client_event_id'] ?? null, isset($event['occurred_at']) ? new \DateTimeImmutable($event['occurred_at']) : null);
        }

        return response()->json(['ids' => collect($stored)->map->getKey()->all(), 'accepted' => count($stored)], 202);
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
database/factories/PaymentFactory.php:32:            'raw_json_encrypted' => null,
app/Services/PaymentService.php:51:            Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'currency' => $order->currency, 'raw_json_encrypted' => json_encode(['reference' => $result->reference, 'message' => $result->message])]);
database/migrations/2026_08_20_000000_create_shop_schema.php:441:            $table->text('raw_json_encrypted')->nullable();
app/Models/Payment.php:12:    protected $fillable = ['order_id', 'provider', 'provider_payment_id', 'method', 'status', 'amount', 'currency', 'raw_json_encrypted'];
app/Models/Payment.php:14:    protected $hidden = ['raw_json_encrypted'];
app/Models/Payment.php:18:        return ['method' => PaymentMethod::class, 'status' => PaymentStatus::class, 'raw_json_encrypted' => 'encrypted'];
<?php

namespace App\Services;

use App\Models\Checkout;
use App\Models\Discount;
use App\Models\TaxSettings;
use App\ValueObjects\PricingResult;

class PricingEngine
{
    public function __construct(private readonly DiscountService $discounts, private readonly ShippingCalculator $shipping, private readonly TaxCalculator $taxes) {}

    public function calculate(Checkout $checkout): PricingResult
    {
        $checkout->load(['cart.lines.variant.product.collections', 'shippingRate']);
        $cart = $checkout->cart;
        $lines = $cart->lines;
        $subtotal = (int) $lines->sum(fn ($line): int => $line->unit_price_amount * $line->quantity);
        $discountAmount = 0;
        $freeShipping = false;

        foreach ($lines as $line) {
            $line->updateQuietly(['line_discount_amount' => 0, 'line_total_amount' => $line->line_subtotal_amount]);
        }

        if ($checkout->discount_code !== null) {
            $discount = Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->first();

            if ($discount?->isAvailable()) {
                $result = $this->discounts->calculate($discount, $subtotal, $lines->map(fn ($line): array => [
                    'line_id' => $line->id,
                    'amount' => $line->unit_price_amount * $line->quantity,
                    'product_id' => $line->variant->product_id,
                    'collection_ids' => $line->variant->product->collections->modelKeys(),
                ])->all());
                $discountAmount = $result->amount;
                $freeShipping = $result->freeShipping;

                foreach ($lines as $line) {
                    $lineDiscount = $result->allocations[$line->id] ?? 0;
                    $line->updateQuietly(['line_discount_amount' => $lineDiscount, 'line_total_amount' => max(0, $line->line_subtotal_amount - $lineDiscount)]);
                }
            }
        }

        $shippingAmount = $checkout->shippingRate !== null && ! $freeShipping ? $this->shipping->calculate($checkout->shippingRate, $cart) : 0;
        $taxSettings = TaxSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $checkout->store_id], ['default_rate_basis_points' => 0]);
        $taxLines = [];

        foreach ($lines as $line) {
            $lineAmount = max(0, $line->line_subtotal_amount - $line->line_discount_amount);
            $taxLines = [...$taxLines, ...$this->taxes->calculate($lineAmount, $taxSettings, $checkout->shipping_address_json ?? [])->lines];
        }

        if ($shippingAmount > 0) {
            $taxLines = [...$taxLines, ...$this->taxes->calculate($shippingAmount, $taxSettings, $checkout->shipping_address_json ?? [])->lines];
        }

        $taxTotal = (int) collect($taxLines)->sum('amount');
        $discountedSubtotal = max(0, $subtotal - $discountAmount);
        $pricesIncludeTax = (bool) $taxSettings->prices_include_tax || $taxSettings->mode === 'inclusive';
        $total = max(0, $discountedSubtotal + $shippingAmount + ($pricesIncludeTax ? 0 : $taxTotal));
        $result = new PricingResult($subtotal, $discountAmount, $shippingAmount, $taxLines, $taxTotal, $total, $cart->currency);
        $checkout->update(['totals_json' => $result->toArray()]);

        return $result;
    }
}
<?php

namespace App\Services;

use App\Contracts\TaxProvider;
use App\Models\TaxSettings;
use App\ValueObjects\TaxCalculationRequest;
use App\ValueObjects\TaxResult;

class TaxCalculator
{
    public function __construct(private readonly ?TaxProvider $provider = null) {}

    public function calculate(int $amount, TaxSettings $settings, array $address): TaxResult
    {
        return ($this->provider ?? new \App\Services\Tax\ManualTaxProvider)->calculate(new TaxCalculationRequest([['amount' => $amount]], 0, $address, $settings));
    }

    public function extractInclusive(int $grossAmount, int $rateBasisPoints): int
    {
        return $rateBasisPoints > 0 ? intdiv($grossAmount * $rateBasisPoints, 10000 + $rateBasisPoints) : 0;
    }

    public function addExclusive(int $netAmount, int $rateBasisPoints): int
    {
        return intdiv($netAmount * $rateBasisPoints, 10000);
    }
}
<?php

namespace App\Services;

use App\Models\Cart;
use App\Models\ShippingRate;
use App\Models\Store;
use Illuminate\Support\Collection;

class ShippingCalculator
{
    public function getAvailableRates(Store $store, array $address): Collection
    {
        $country = strtoupper((string) ($address['country_code'] ?? ''));
        $region = strtoupper((string) ($address['province_code'] ?? ''));

        $rates = ShippingRate::query()->where('is_active', true)->whereHas('zone', fn ($query) => $query->where('store_id', $store->getKey()))->with('zone')->get();

        $matching = $rates->filter(function (ShippingRate $rate) use ($country, $region): bool {
            $countries = array_map('strtoupper', $rate->zone->countries_json ?? []);
            $regions = array_map('strtoupper', $rate->zone->regions_json ?? []);

            return ($region !== '' && in_array($region, $regions, true))
                || ($country !== '' && in_array($country, $countries, true))
                || ($countries === [] && $regions === []);
        });

        $specificity = $matching->groupBy(function (ShippingRate $rate) use ($country, $region): int {
            $countries = array_map('strtoupper', $rate->zone->countries_json ?? []);
            $regions = array_map('strtoupper', $rate->zone->regions_json ?? []);

            return ($region !== '' && in_array($region, $regions, true)) ? 2 : (($country !== '' && in_array($country, $countries, true)) ? 1 : 0);
        });

        return $specificity->sortKeysDesc()->first() ?? collect();
    }

    public function calculate(ShippingRate $rate, Cart $cart): int
    {
        $lines = $cart->load('lines.variant')->lines;
        $lines = $lines->filter(fn ($line): bool => (bool) $line->variant->requires_shipping);
        $weight = (int) $lines->sum(fn ($line): int => $line->quantity * ($line->variant->weight_g ?? $line->variant->weight_grams));
        $subtotal = (int) $lines->sum('line_total_amount');
        $config = $rate->config_json ?? [];

        return match ($rate->type) {
            'weight' => $this->rangeAmount($config['ranges'] ?? [], $weight, $rate->price_amount),
            'price' => $this->rangeAmount($config['ranges'] ?? [], $subtotal, $rate->price_amount),
            default => $rate->price_amount,
        };
    }

    private function rangeAmount(array $ranges, int $value, int $fallback): int
    {
        foreach ($ranges as $range) {
            if ($value >= (int) ($range['min_g'] ?? $range['min_amount'] ?? 0) && $value <= (int) ($range['max_g'] ?? $range['max_amount'] ?? PHP_INT_MAX)) {
                return (int) ($range['amount'] ?? $fallback);
            }
        }

        return 0;
    }
}
<?php

namespace App\Services;

use App\Models\AnalyticsEvent;
use App\Models\Store;
use Illuminate\Support\Collection;

class AnalyticsService
{
    /** @var array<int, string> */
    private array $eventTypes = ['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search'];

    public function track(Store $store, string $type, array $properties = [], ?string $sessionId = null, ?int $customerId = null, ?string $clientEventId = null, ?\DateTimeInterface $occurredAt = null): AnalyticsEvent
    {
        if (! in_array($type, $this->eventTypes, true)) {
            throw new \InvalidArgumentException('Unsupported analytics event type.');
        }

        if ($clientEventId !== null) {
            $existing = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->getKey())->where('client_event_id', $clientEventId)->first();

            if ($existing !== null) {
                return $existing;
            }
        }

        return AnalyticsEvent::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'type' => $type, 'session_id' => $sessionId, 'customer_id' => $customerId, 'client_event_id' => $clientEventId, 'payload' => $properties, 'properties_json' => $properties, 'occurred_at' => $occurredAt ?? now()]);
    }

    public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection
    {
        return \App\Models\AnalyticsDaily::withoutGlobalScopes()->where('store_id', $store->getKey())->whereBetween('date', [$startDate, $endDate])->orderBy('date')->get();
    }
}
<?php

namespace App\Jobs;

use App\Models\AnalyticsDaily;
use App\Models\AnalyticsEvent;
use App\Models\Order;
use App\Models\Store;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class AggregateAnalytics implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function __construct(public ?Store $store = null, public ?string $date = null) {}

    public function handle(): void
    {
        $date = CarbonImmutable::parse($this->date ?? now()->subDay()->toDateString());
        $stores = $this->store === null ? Store::query()->get() : collect([$this->store]);

        foreach ($stores as $store) {
            $events = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('created_at', $date)->get();
            $orders = Order::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('placed_at', $date)->whereIn('financial_status', ['paid', 'partially_refunded'])->get();
            $revenue = (int) $orders->sum('total_amount');

            AnalyticsDaily::withoutGlobalScopes()->newQuery()->updateOrInsert(
                ['store_id' => $store->getKey(), 'date' => $date->toDateString()],
                [
                    'orders_count' => $orders->count(),
                    'revenue_amount' => $revenue,
                    'aov_amount' => $orders->count() > 0 ? intdiv($revenue, $orders->count()) : 0,
                    'visits_count' => $events->where('type', 'page_view')->count(),
                    'add_to_cart_count' => $events->where('type', 'add_to_cart')->count(),
                    'checkout_started_count' => $events->where('type', 'checkout_started')->count(),
                    'checkout_completed_count' => $events->where('type', 'checkout_completed')->count(),
                ],
            );
        }
    }
}
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\AnalyticsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StorefrontAnalyticsController extends Controller
{
    public function __construct(private readonly AnalyticsService $analytics) {}

    public function store(Request $request): JsonResponse
    {
        $data = $request->validate([
            'events' => ['sometimes', 'array', 'min:1', 'max:100'],
            'events.*.type' => ['required_with:events', 'string'],
            'events.*.properties' => ['nullable', 'array'],
            'events.*.session_id' => ['nullable', 'string', 'max:255'],
            'events.*.client_event_id' => ['nullable', 'string', 'max:255'],
            'events.*.occurred_at' => ['nullable', 'date'],
            'type' => ['required_without:events', 'string'],
            'properties' => ['nullable', 'array'],
            'session_id' => ['nullable', 'string', 'max:255'],
            'client_event_id' => ['nullable', 'string', 'max:255'],
            'occurred_at' => ['nullable', 'date'],
        ]);
        $events = $data['events'] ?? [$data];
        $stored = [];
        foreach ($events as $event) {
            $stored[] = $this->analytics->track(app('current_store'), $event['type'], $event['properties'] ?? [], $event['session_id'] ?? ($request->hasSession() ? $request->session()->getId() : null), $request->user('customer')?->getKey(), $event['client_event_id'] ?? null, isset($event['occurred_at']) ? new \DateTimeImmutable($event['occurred_at']) : null);
        }

        return response()->json(['ids' => collect($stored)->map->getKey()->all(), 'accepted' => count($stored)], 202);
    }
}


exec

status: completed

input:
const r = await tools.exec_command({cmd:"rg -n \"admin@acme|User::|users\" database/seeders | head -120; sed -n '1,180p' database/seeders/UserSeeder.php 2>/dev/null || true; sed -n '1,220p' app/Http/Requests/StoreInvitationRequest.php; sed -n '1,120p' app/Http/Requests/CreateFulfillmentRequest.php; sed -n '1,100p' app/Http/Requests/CreateRefundRequest.php","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":18000});
text(r.output);


type: CommandExecution

command: rg -n "admin@acme|User::|users" database/seeders | head -120; sed -n '1,180p' database/seeders/UserSeeder.php 2>/dev/null || true; sed -n '1,220p' app/Http/Requests/StoreInvitationRequest.php; sed -n '1,120p' app/Http/Requests/CreateFulfillmentRequest.php; sed -n '1,100p' app/Http/Requests/CreateRefundRequest.php

type: unknown

status: completed

stdout:
database/seeders/OrderSeeder.php:121:            array_merge($base, ['order_number' => '#1013', 'customer' => 'robert@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDay(), 'lines' => [['handle' => 'wide-leg-trousers', 'variant' => 'M', 'quantity' => 1], ['handle' => 'wool-scarf', 'variant' => 'Burgundy', 'quantity' => 1]], 'subtotal_amount' => 7998, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1277, 'total_amount' => 8497, 'provider_payment_id' => 'mock_test_order1013', 'payment_status' => PaymentStatus::Captured]),
database/seeders/UserSeeder.php:17:            ['email' => 'admin@acme.test', 'name' => 'Admin User', 'last_login_at' => now()],
database/seeders/UserSeeder.php:25:            User::query()->updateOrCreate(
database/seeders/ProductSeeder.php:131:            $this->product('Wide Leg Trousers', 'wide-leg-trousers', 'ACME-TROUSER', 'Acme Denim', 'Pants', ['sale'], 4999, 6999, 550, 7, ['Size' => ['S', 'M', 'L']], ['pants-jeans', 'sale']),
database/seeders/StoreUserSeeder.php:17:        $users = User::query()->whereIn('email', ['admin@acme.test', 'staff@acme.test', 'support@acme.test', 'manager@acme.test', 'admin2@acme.test'])->get()->keyBy('email');
database/seeders/StoreUserSeeder.php:20:            ['email' => 'admin@acme.test', 'store' => 'acme-fashion', 'role' => 'owner'],
database/seeders/StoreUserSeeder.php:27:            $user = $users->get($membership['email']);
database/seeders/StoreUserSeeder.php:30:                $store->users()->syncWithoutDetaching([$user->getKey() => ['role' => $membership['role']]]);
<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class UserSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        foreach ([
            ['email' => 'admin@acme.test', 'name' => 'Admin User', 'last_login_at' => now()],
            ['email' => 'staff@acme.test', 'name' => 'Staff User', 'last_login_at' => now()->subDays(2)],
            ['email' => 'support@acme.test', 'name' => 'Support User', 'last_login_at' => now()->subDay()],
            ['email' => 'manager@acme.test', 'name' => 'Store Manager', 'last_login_at' => now()->subDay()],
            ['email' => 'admin2@acme.test', 'name' => 'Admin Two', 'last_login_at' => now()->subDay()],
        ] as $user) {
            $password = Hash::make('password');

            User::query()->updateOrCreate(
                ['email' => $user['email']],
                ['name' => $user['name'], 'password' => $password, 'password_hash' => $password, 'status' => 'active', 'email_verified_at' => now(), 'last_login_at' => $user['last_login_at']],
            );
        }
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreInvitationRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return (bool) $this->user('sanctum')?->tokenCan('manage-platform');
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'email', 'max:255'],
            'role' => ['required', 'in:owner,admin,staff,support'],
        ];
    }
}
<?php

namespace App\Http\Requests;

use App\Models\Order;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class CreateFulfillmentRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return Gate::allows('viewAny', Order::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'lines' => ['required', 'array', 'min:1'],
            'lines.*.order_line_id' => ['required', 'integer'],
            'lines.*.quantity' => ['required', 'integer', 'min:1'],
            'tracking_company' => ['nullable', 'string', 'max:100'],
            'tracking_number' => ['nullable', 'string', 'max:255'],
            'tracking_url' => ['nullable', 'url'],
        ];
    }
}
<?php

namespace App\Http\Requests;

use App\Models\Order;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class CreateRefundRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return Gate::allows('viewAny', Order::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'payment_id' => ['nullable', 'integer'],
            'amount' => ['nullable', 'integer', 'min:1'],
            'lines' => ['nullable', 'array'],
            'lines.*.order_line_id' => ['required_with:lines', 'integer'],
            'lines.*.quantity' => ['required_with:lines', 'integer', 'min:1'],
            'reason' => ['nullable', 'string', 'max:500'],
            'restock' => ['nullable', 'boolean'],
        ];
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
database/seeders/OrderSeeder.php:121:            array_merge($base, ['order_number' => '#1013', 'customer' => 'robert@example.com', 'payment_method' => PaymentMethod::CreditCard, 'status' => OrderStatus::Paid, 'financial_status' => FinancialStatus::Paid, 'fulfillment_status' => FulfillmentStatus::Unfulfilled, 'placed_at' => now()->subDay(), 'lines' => [['handle' => 'wide-leg-trousers', 'variant' => 'M', 'quantity' => 1], ['handle' => 'wool-scarf', 'variant' => 'Burgundy', 'quantity' => 1]], 'subtotal_amount' => 7998, 'discount_amount' => 0, 'shipping_amount' => 499, 'tax_amount' => 1277, 'total_amount' => 8497, 'provider_payment_id' => 'mock_test_order1013', 'payment_status' => PaymentStatus::Captured]),
database/seeders/UserSeeder.php:17:            ['email' => 'admin@acme.test', 'name' => 'Admin User', 'last_login_at' => now()],
database/seeders/UserSeeder.php:25:            User::query()->updateOrCreate(
database/seeders/ProductSeeder.php:131:            $this->product('Wide Leg Trousers', 'wide-leg-trousers', 'ACME-TROUSER', 'Acme Denim', 'Pants', ['sale'], 4999, 6999, 550, 7, ['Size' => ['S', 'M', 'L']], ['pants-jeans', 'sale']),
database/seeders/StoreUserSeeder.php:17:        $users = User::query()->whereIn('email', ['admin@acme.test', 'staff@acme.test', 'support@acme.test', 'manager@acme.test', 'admin2@acme.test'])->get()->keyBy('email');
database/seeders/StoreUserSeeder.php:20:            ['email' => 'admin@acme.test', 'store' => 'acme-fashion', 'role' => 'owner'],
database/seeders/StoreUserSeeder.php:27:            $user = $users->get($membership['email']);
database/seeders/StoreUserSeeder.php:30:                $store->users()->syncWithoutDetaching([$user->getKey() => ['role' => $membership['role']]]);
<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class UserSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        foreach ([
            ['email' => 'admin@acme.test', 'name' => 'Admin User', 'last_login_at' => now()],
            ['email' => 'staff@acme.test', 'name' => 'Staff User', 'last_login_at' => now()->subDays(2)],
            ['email' => 'support@acme.test', 'name' => 'Support User', 'last_login_at' => now()->subDay()],
            ['email' => 'manager@acme.test', 'name' => 'Store Manager', 'last_login_at' => now()->subDay()],
            ['email' => 'admin2@acme.test', 'name' => 'Admin Two', 'last_login_at' => now()->subDay()],
        ] as $user) {
            $password = Hash::make('password');

            User::query()->updateOrCreate(
                ['email' => $user['email']],
                ['name' => $user['name'], 'password' => $password, 'password_hash' => $password, 'status' => 'active', 'email_verified_at' => now(), 'last_login_at' => $user['last_login_at']],
            );
        }
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreInvitationRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return (bool) $this->user('sanctum')?->tokenCan('manage-platform');
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'email', 'max:255'],
            'role' => ['required', 'in:owner,admin,staff,support'],
        ];
    }
}
<?php

namespace App\Http\Requests;

use App\Models\Order;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class CreateFulfillmentRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return Gate::allows('viewAny', Order::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'lines' => ['required', 'array', 'min:1'],
            'lines.*.order_line_id' => ['required', 'integer'],
            'lines.*.quantity' => ['required', 'integer', 'min:1'],
            'tracking_company' => ['nullable', 'string', 'max:100'],
            'tracking_number' => ['nullable', 'string', 'max:255'],
            'tracking_url' => ['nullable', 'url'],
        ];
    }
}
<?php

namespace App\Http\Requests;

use App\Models\Order;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class CreateRefundRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return Gate::allows('viewAny', Order::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'payment_id' => ['nullable', 'integer'],
            'amount' => ['nullable', 'integer', 'min:1'],
            'lines' => ['nullable', 'array'],
            'lines.*.order_line_id' => ['required_with:lines', 'integer'],
            'lines.*.quantity' => ['required_with:lines', 'integer', 'min:1'],
            'reason' => ['nullable', 'string', 'max:500'],
            'restock' => ['nullable', 'boolean'],
        ];
    }
}


status: completed

input:
const r = await tools.exec_command({cmd:"find app/Policies -type f -maxdepth 2 -print -exec sed -n '1,180p' {} \\;; rg -n \"Gate::define|Policy|AuthServiceProvider\" app bootstrap","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":25000});
text(r.output);


command: find app/Policies -type f -maxdepth 2 -print -exec sed -n '1,180p' {} \;; rg -n "Gate::define|Policy|AuthServiceProvider" app bootstrap

cmd: find app/Policies -type f -maxdepth 2 -print -exec sed -n '1,180p' '{}' "\\;"

path: Policies

type: search

cmd: rg -n 'Gate::define|Policy|AuthServiceProvider' app bootstrap

query: Gate::define|Policy|AuthServiceProvider

path: app

stdout:
app/Policies/CollectionPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Collection;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class CollectionPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Collection $collection): bool
    {
        return $this->userHasModelStoreRole($user, $collection, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function update(User $user, Collection $collection): bool
    {
        return $this->userHasModelStoreRole($user, $collection, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function delete(User $user, Collection $collection): bool
    {
        return $this->userHasModelStoreRole($user, $collection, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
app/Policies/ThemePolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Theme;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class ThemePolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Theme $theme): bool
    {
        return $this->userHasModelStoreRole($user, $theme, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function update(User $user, Theme $theme): bool
    {
        return $this->userHasModelStoreRole($user, $theme, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function delete(User $user, Theme $theme): bool
    {
        return $this->userHasModelStoreRole($user, $theme, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function publish(User $user, Theme $theme): bool
    {
        return $this->update($user, $theme);
    }
}
app/Policies/ProductPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Product;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class ProductPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Product $product): bool
    {
        return $this->userHasModelStoreRole($user, $product, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function update(User $user, Product $product): bool
    {
        return $this->userHasModelStoreRole($user, $product, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function delete(User $user, Product $product): bool
    {
        return $this->userHasModelStoreRole($user, $product, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function archive(User $user, Product $product): bool
    {
        return $this->delete($user, $product);
    }

    public function restore(User $user, Product $product): bool
    {
        return $this->delete($user, $product);
    }
}
app/Policies/FulfillmentPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Fulfillment;
use App\Models\Order;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class FulfillmentPolicy
{
    use ChecksStoreRole;

    public function create(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function view(User $user, Fulfillment $fulfillment): bool
    {
        return $this->userHasModelStoreRole($user, $fulfillment->loadMissing('order')->order, StoreUserRole::cases());
    }
}
app/Policies/StorePolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Store;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class StorePolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $user->stores()->exists();
    }

    public function view(User $user, Store $store): bool
    {
        return $this->userHasModelStoreRole($user, $store, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function update(User $user, Store $store): bool
    {
        return $this->userHasModelStoreRole($user, $store, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function delete(User $user, Store $store): bool
    {
        return $this->userHasModelStoreRole($user, $store, [StoreUserRole::Owner]);
    }

    public function restore(User $user, Store $store): bool
    {
        return $this->delete($user, $store);
    }

    public function forceDelete(User $user, Store $store): bool
    {
        return $this->delete($user, $store);
    }
}
app/Policies/RefundPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Order;
use App\Models\Refund;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class RefundPolicy
{
    use ChecksStoreRole;

    public function create(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function view(User $user, Refund $refund): bool
    {
        return $this->userHasModelStoreRole($user, $refund->loadMissing('order')->order, StoreUserRole::cases());
    }
}
app/Policies/CustomerPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Customer;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class CustomerPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Customer $customer): bool
    {
        return $this->userHasModelStoreRole($user, $customer, StoreUserRole::cases());
    }

    public function update(User $user, Customer $customer): bool
    {
        return $this->userHasModelStoreRole($user, $customer, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }
}
app/Policies/PagePolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Page;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class PagePolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Page $page): bool
    {
        return $this->userHasModelStoreRole($user, $page, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function update(User $user, Page $page): bool
    {
        return $this->userHasModelStoreRole($user, $page, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function delete(User $user, Page $page): bool
    {
        return $this->userHasModelStoreRole($user, $page, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
app/Policies/OrderPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Order;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class OrderPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, StoreUserRole::cases());
    }

    public function update(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function cancel(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function createFulfillment(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function createRefund(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
app/Policies/DiscountPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Discount;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class DiscountPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Discount $discount): bool
    {
        return $this->userHasModelStoreRole($user, $discount, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function update(User $user, Discount $discount): bool
    {
        return $this->userHasModelStoreRole($user, $discount, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function delete(User $user, Discount $discount): bool
    {
        return $this->userHasModelStoreRole($user, $discount, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
app/Livewire/Admin/Inventory/Index.php:5:use App\Enums\InventoryPolicy;
app/Livewire/Admin/Inventory/Index.php:59:            $this->policies[$item->id] ??= $item->policy instanceof InventoryPolicy ? $item->policy->value : (string) $item->policy;
app/Policies/DiscountPolicy.php:10:class DiscountPolicy
app/Policies/OrderPolicy.php:10:class OrderPolicy
app/Policies/PagePolicy.php:10:class PagePolicy
app/Policies/CustomerPolicy.php:10:class CustomerPolicy
app/Policies/RefundPolicy.php:11:class RefundPolicy
app/Policies/ProductPolicy.php:10:class ProductPolicy
app/Policies/StorePolicy.php:10:class StorePolicy
app/Policies/ThemePolicy.php:10:class ThemePolicy
app/Enums/InventoryPolicy.php:5:enum InventoryPolicy: string
app/Policies/FulfillmentPolicy.php:11:class FulfillmentPolicy
app/Policies/CollectionPolicy.php:10:class CollectionPolicy
app/Models/InventoryItem.php:5:use App\Enums\InventoryPolicy;
app/Models/InventoryItem.php:18:        return ['policy' => InventoryPolicy::class];
app/Models/InventoryItem.php:33:        return $this->policy === InventoryPolicy::Continue || $this->availableQuantity() >= $quantity;


text:
Script completed
Wall time 0.1 seconds
Output:

text:
app/Policies/CollectionPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Collection;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class CollectionPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Collection $collection): bool
    {
        return $this->userHasModelStoreRole($user, $collection, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function update(User $user, Collection $collection): bool
    {
        return $this->userHasModelStoreRole($user, $collection, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function delete(User $user, Collection $collection): bool
    {
        return $this->userHasModelStoreRole($user, $collection, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
app/Policies/ThemePolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Theme;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class ThemePolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Theme $theme): bool
    {
        return $this->userHasModelStoreRole($user, $theme, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function update(User $user, Theme $theme): bool
    {
        return $this->userHasModelStoreRole($user, $theme, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function delete(User $user, Theme $theme): bool
    {
        return $this->userHasModelStoreRole($user, $theme, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function publish(User $user, Theme $theme): bool
    {
        return $this->update($user, $theme);
    }
}
app/Policies/ProductPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Product;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class ProductPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Product $product): bool
    {
        return $this->userHasModelStoreRole($user, $product, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function update(User $user, Product $product): bool
    {
        return $this->userHasModelStoreRole($user, $product, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function delete(User $user, Product $product): bool
    {
        return $this->userHasModelStoreRole($user, $product, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function archive(User $user, Product $product): bool
    {
        return $this->delete($user, $product);
    }

    public function restore(User $user, Product $product): bool
    {
        return $this->delete($user, $product);
    }
}
app/Policies/FulfillmentPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Fulfillment;
use App\Models\Order;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class FulfillmentPolicy
{
    use ChecksStoreRole;

    public function create(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function view(User $user, Fulfillment $fulfillment): bool
    {
        return $this->userHasModelStoreRole($user, $fulfillment->loadMissing('order')->order, StoreUserRole::cases());
    }
}
app/Policies/StorePolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Store;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class StorePolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $user->stores()->exists();
    }

    public function view(User $user, Store $store): bool
    {
        return $this->userHasModelStoreRole($user, $store, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function update(User $user, Store $store): bool
    {
        return $this->userHasModelStoreRole($user, $store, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function delete(User $user, Store $store): bool
    {
        return $this->userHasModelStoreRole($user, $store, [StoreUserRole::Owner]);
    }

    public function restore(User $user, Store $store): bool
    {
        return $this->delete($user, $store);
    }

    public function forceDelete(User $user, Store $store): bool
    {
        return $this->delete($user, $store);
    }
}
app/Policies/RefundPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Order;
use App\Models\Refund;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class RefundPolicy
{
    use ChecksStoreRole;

    public function create(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function view(User $user, Refund $refund): bool
    {
        return $this->userHasModelStoreRole($user, $refund->loadMissing('order')->order, StoreUserRole::cases());
    }
}
app/Policies/CustomerPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Customer;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class CustomerPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Customer $customer): bool
    {
        return $this->userHasModelStoreRole($user, $customer, StoreUserRole::cases());
    }

    public function update(User $user, Customer $customer): bool
    {
        return $this->userHasModelStoreRole($user, $customer, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }
}
app/Policies/PagePolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Page;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class PagePolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Page $page): bool
    {
        return $this->userHasModelStoreRole($user, $page, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function update(User $user, Page $page): bool
    {
        return $this->userHasModelStoreRole($user, $page, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function delete(User $user, Page $page): bool
    {
        return $this->userHasModelStoreRole($user, $page, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
app/Policies/OrderPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Order;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class OrderPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, StoreUserRole::cases());
    }

    public function update(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function cancel(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }

    public function createFulfillment(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function createRefund(User $user, Order $order): bool
    {
        return $this->userHasModelStoreRole($user, $order, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
app/Policies/DiscountPolicy.php
<?php

namespace App\Policies;

use App\Enums\StoreUserRole;
use App\Models\Discount;
use App\Models\User;
use App\Traits\ChecksStoreRole;

class DiscountPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, StoreUserRole::cases());
    }

    public function view(User $user, Discount $discount): bool
    {
        return $this->userHasModelStoreRole($user, $discount, StoreUserRole::cases());
    }

    public function create(User $user): bool
    {
        return $this->userHasCurrentStoreRole($user, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function update(User $user, Discount $discount): bool
    {
        return $this->userHasModelStoreRole($user, $discount, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
    }

    public function delete(User $user, Discount $discount): bool
    {
        return $this->userHasModelStoreRole($user, $discount, [StoreUserRole::Owner, StoreUserRole::Admin]);
    }
}
app/Livewire/Admin/Inventory/Index.php:5:use App\Enums\InventoryPolicy;
app/Livewire/Admin/Inventory/Index.php:59:            $this->policies[$item->id] ??= $item->policy instanceof InventoryPolicy ? $item->policy->value : (string) $item->policy;
app/Policies/DiscountPolicy.php:10:class DiscountPolicy
app/Policies/OrderPolicy.php:10:class OrderPolicy
app/Policies/PagePolicy.php:10:class PagePolicy
app/Policies/CustomerPolicy.php:10:class CustomerPolicy
app/Policies/RefundPolicy.php:11:class RefundPolicy
app/Policies/ProductPolicy.php:10:class ProductPolicy
app/Policies/StorePolicy.php:10:class StorePolicy
app/Policies/ThemePolicy.php:10:class ThemePolicy
app/Enums/InventoryPolicy.php:5:enum InventoryPolicy: string
app/Policies/FulfillmentPolicy.php:11:class FulfillmentPolicy
app/Policies/CollectionPolicy.php:10:class CollectionPolicy
app/Models/InventoryItem.php:5:use App\Enums\InventoryPolicy;
app/Models/InventoryItem.php:18:        return ['policy' => InventoryPolicy::class];
app/Models/InventoryItem.php:33:        return $this->policy === InventoryPolicy::Continue || $this->availableQuantity() >= $quantity;


status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,120p' app/ValueObjects/PaymentResult.php; sed -n '1,220p' specs/02-API-ROUTES.md | rg -n \"order|HMAC|token|orders/\" -C 4 | head -100; sed -n '1,180p' app/Http/Controllers/Api/StorefrontOrderController.php; sed -n '1,120p' app/Services/OrderAccessTokenService.php 2>/dev/null || true; rg -n \"access token|hmac|signature\" app/Services app/Http/Controllers/Api specs/02-API-ROUTES.md | head -100","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":18000});
text(r.output);


command: sed -n '1,120p' app/ValueObjects/PaymentResult.php; sed -n '1,220p' specs/02-API-ROUTES.md | rg -n "order|HMAC|token|orders/" -C 4 | head -100; sed -n '1,180p' app/Http/Controllers/Api/StorefrontOrderController.php; sed -n '1,120p' app/Services/OrderAccessTokenService.php 2>/dev/null || true; rg -n "access token|hmac|signature" app/Services app/Http/Controllers/Api specs/02-API-ROUTES.md | head -100

stdout:
<?php

namespace App\ValueObjects;

use App\Enums\PaymentStatus;

readonly class PaymentResult
{
    public function __construct(public PaymentStatus $status, public string $reference, public string $message = '') {}

    public function isSuccessful(): bool
    {
        return in_array($this->status, [PaymentStatus::Captured, PaymentStatus::Pending], true);
    }
}
18-| POST | `/admin/login` | Process admin login | Handled by Livewire action |
19-| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |
20-| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\Auth\ForgotPassword` |
21-| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |
22:| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\Auth\ResetPassword` |
23:| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |
24-
25-Rate limit: `login` tier (5 per minute per IP).
26-
27----
--
38-#### Dashboard
39-
40-| Method | Path | Description | Component |
41-|--------|------|-------------|-----------|
42:| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\Dashboard` |
43-
44-#### Products
45-
46-| Method | Path | Description | Component |
--
66-#### Orders
67-
68-| Method | Path | Description | Component |
69-|--------|------|-------------|-----------|
70:| GET | `/admin/orders` | Order list with status filters | `Admin\Orders\Index` |
71:| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |
72-
73-#### Customers
74-
75-| Method | Path | Description | Component |
76-|--------|------|-------------|-----------|
77-| GET | `/admin/customers` | Customer list | `Admin\Customers\Index` |
78:| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |
79-
80-#### Discounts
81-
82-| Method | Path | Description | Component |
--
124-#### Developers
125-
126-| Method | Path | Description | Component |
127-|--------|------|-------------|-----------|
128:| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |
129-
130-> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.
131-
132-#### Analytics
--
165-
166-| Method | Path | Description | Component |
167-|--------|------|-------------|-----------|
168-| GET | `/account` | Customer account overview | `Storefront\Account\Dashboard` |
169:| GET | `/account/orders` | Order history list | `Storefront\Account\Orders\Index` |
170:| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\Account\Orders\Show` |
171-| GET | `/account/addresses` | Address book management | `Storefront\Account\Addresses\Index` |
172-
173-#### Customer Auth Routes (no auth required)
174-
--
177-| GET | `/account/login` | Customer login page | `Storefront\Account\Auth\Login` |
178-| GET | `/account/register` | Customer registration page | `Storefront\Account\Auth\Register` |
179-| GET | `/forgot-password` | Forgot password page | `Storefront\Account\Auth\ForgotPassword` |
180-| POST | `/forgot-password` | Send reset link | Handled by Livewire action |
181:| GET | `/reset-password/{token}` | Reset password form | `Storefront\Account\Auth\ResetPassword` |
182-| POST | `/reset-password` | Reset password | Handled by Livewire action |
183-
184----
185-
--
189-
190-| Method | Path | Description | Component |
191-|--------|------|-------------|-----------|
192-| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\Checkout\Show` |
193:| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\Checkout\Confirmation` |
194-
195----
196-
197-## 2. Storefront REST API
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StorefrontOrderController extends Controller
{
    public function show(Request $request, string $orderNumber): JsonResponse
    {
        $order = Order::query()->with(['lines', 'payments', 'fulfillments.lines'])->where('order_number', $orderNumber)->firstOrFail();
        $customerId = $request->user('customer')?->getKey();
        $token = (string) $request->query('token', '');
        $expected = hash_hmac('sha256', $order->order_number, (string) config('app.key'));
        abort_unless(($customerId !== null && (int) $order->customer_id === (int) $customerId) || ($token !== '' && hash_equals($expected, $token)), 404);

        return response()->json(['data' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'fulfillment_status' => $order->fulfillment_status, 'currency' => $order->currency, 'email' => $order->email, 'subtotal_amount' => $order->subtotal_amount, 'discount_amount' => $order->discount_amount, 'shipping_amount' => $order->shipping_amount, 'tax_amount' => $order->tax_amount, 'total_amount' => $order->total_amount, 'shipping_address' => $order->shipping_address_json, 'lines' => $order->lines->map(fn ($line): array => ['id' => $line->id, 'title' => $line->title_snapshot, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'total_amount' => $line->line_total_amount])->all(), 'payments' => $order->payments->map(fn ($payment): array => ['method' => $payment->method, 'status' => $payment->status, 'amount' => $payment->amount])->all(), 'fulfillments' => $order->fulfillments->map(fn ($fulfillment): array => ['id' => $fulfillment->id, 'status' => $fulfillment->status, 'tracking_number' => $fulfillment->tracking_number])->all()]]);
    }
}
specs/02-API-ROUTES.md:962:- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)
specs/02-API-ROUTES.md:969:| `token` | string | Yes | HMAC-signed access token for this order |
app/Http/Controllers/Api/StorefrontOrderController.php:17:        $expected = hash_hmac('sha256', $order->order_number, (string) config('app.key'));
app/Services/WebhookService.php:28:        return hash_hmac('sha256', $payload, $secret);
app/Services/WebhookService.php:31:    public function verify(string $payload, string $signature, string $secret): bool
app/Services/WebhookService.php:33:        return hash_equals($this->sign($payload, $secret), $signature);


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\ValueObjects;

use App\Enums\PaymentStatus;

readonly class PaymentResult
{
    public function __construct(public PaymentStatus $status, public string $reference, public string $message = '') {}

    public function isSuccessful(): bool
    {
        return in_array($this->status, [PaymentStatus::Captured, PaymentStatus::Pending], true);
    }
}
18-| POST | `/admin/login` | Process admin login | Handled by Livewire action |
19-| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |
20-| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\Auth\ForgotPassword` |
21-| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |
22:| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\Auth\ResetPassword` |
23:| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |
24-
25-Rate limit: `login` tier (5 per minute per IP).
26-
27----
--
38-#### Dashboard
39-
40-| Method | Path | Description | Component |
41-|--------|------|-------------|-----------|
42:| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\Dashboard` |
43-
44-#### Products
45-
46-| Method | Path | Description | Component |
--
66-#### Orders
67-
68-| Method | Path | Description | Component |
69-|--------|------|-------------|-----------|
70:| GET | `/admin/orders` | Order list with status filters | `Admin\Orders\Index` |
71:| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |
72-
73-#### Customers
74-
75-| Method | Path | Description | Component |
76-|--------|------|-------------|-----------|
77-| GET | `/admin/customers` | Customer list | `Admin\Customers\Index` |
78:| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |
79-
80-#### Discounts
81-
82-| Method | Path | Description | Component |
--
124-#### Developers
125-
126-| Method | Path | Description | Component |
127-|--------|------|-------------|-----------|
128:| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |
129-
130-> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.
131-
132-#### Analytics
--
165-
166-| Method | Path | Description | Component |
167-|--------|------|-------------|-----------|
168-| GET | `/account` | Customer account overview | `Storefront\Account\Dashboard` |
169:| GET | `/account/orders` | Order history list | `Storefront\Account\Orders\Index` |
170:| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\Account\Orders\Show` |
171-| GET | `/account/addresses` | Address book management | `Storefront\Account\Addresses\Index` |
172-
173-#### Customer Auth Routes (no auth required)
174-
--
177-| GET | `/account/login` | Customer login page | `Storefront\Account\Auth\Login` |
178-| GET | `/account/register` | Customer registration page | `Storefront\Account\Auth\Register` |
179-| GET | `/forgot-password` | Forgot password page | `Storefront\Account\Auth\ForgotPassword` |
180-| POST | `/forgot-password` | Send reset link | Handled by Livewire action |
181:| GET | `/reset-password/{token}` | Reset password form | `Storefront\Account\Auth\ResetPassword` |
182-| POST | `/reset-password` | Reset password | Handled by Livewire action |
183-
184----
185-
--
189-
190-| Method | Path | Description | Component |
191-|--------|------|-------------|-----------|
192-| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\Checkout\Show` |
193:| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\Checkout\Confirmation` |
194-
195----
196-
197-## 2. Storefront REST API
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StorefrontOrderController extends Controller
{
    public function show(Request $request, string $orderNumber): JsonResponse
    {
        $order = Order::query()->with(['lines', 'payments', 'fulfillments.lines'])->where('order_number', $orderNumber)->firstOrFail();
        $customerId = $request->user('customer')?->getKey();
        $token = (string) $request->query('token', '');
        $expected = hash_hmac('sha256', $order->order_number, (string) config('app.key'));
        abort_unless(($customerId !== null && (int) $order->customer_id === (int) $customerId) || ($token !== '' && hash_equals($expected, $token)), 404);

        return response()->json(['data' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'fulfillment_status' => $order->fulfillment_status, 'currency' => $order->currency, 'email' => $order->email, 'subtotal_amount' => $order->subtotal_amount, 'discount_amount' => $order->discount_amount, 'shipping_amount' => $order->shipping_amount, 'tax_amount' => $order->tax_amount, 'total_amount' => $order->total_amount, 'shipping_address' => $order->shipping_address_json, 'lines' => $order->lines->map(fn ($line): array => ['id' => $line->id, 'title' => $line->title_snapshot, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'total_amount' => $line->line_total_amount])->all(), 'payments' => $order->payments->map(fn ($payment): array => ['method' => $payment->method, 'status' => $payment->status, 'amount' => $payment->amount])->all(), 'fulfillments' => $order->fulfillments->map(fn ($fulfillment): array => ['id' => $fulfillment->id, 'status' => $fulfillment->status, 'tracking_number' => $fulfillment->tracking_number])->all()]]);
    }
}
specs/02-API-ROUTES.md:962:- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)
specs/02-API-ROUTES.md:969:| `token` | string | Yes | HMAC-signed access token for this order |
app/Http/Controllers/Api/StorefrontOrderController.php:17:        $expected = hash_hmac('sha256', $order->order_number, (string) config('app.key'));
app/Services/WebhookService.php:28:        return hash_hmac('sha256', $payload, $secret);
app/Services/WebhookService.php:31:    public function verify(string $payload, string $signature, string $secret): bool
app/Services/WebhookService.php:33:        return hash_equals($this->sign($payload, $secret), $signature);


status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '950,1040p' specs/02-API-ROUTES.md; sed -n '1,160p' app/Models/AnalyticsEvent.php; sed -n '1,160p' app/Models/TaxSettings.php; sed -n '1,130p' app/Models/ShippingRate.php; sed -n '1,130p' app/Models/ShippingZone.php; sed -n '1,100p' app/Http/Requests/StoreShippingZoneRequest.php; sed -n '1,100p' app/Http/Requests/StoreShippingRateRequest.php; sed -n '1,100p' app/Http/Requests/UpdateTaxSettingsRequest.php; sed -n '1,100p' app/Http/Requests/StoreThemeRequest.php; sed -n '1,100p' app/Http/Requests/UpdateThemeSettingsRequest.php","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":26000});
text(r.output);


command: sed -n '950,1040p' specs/02-API-ROUTES.md; sed -n '1,160p' app/Models/AnalyticsEvent.php; sed -n '1,160p' app/Models/TaxSettings.php; sed -n '1,130p' app/Models/ShippingRate.php; sed -n '1,130p' app/Models/ShippingZone.php; sed -n '1,100p' app/Http/Requests/StoreShippingZoneRequest.php; sed -n '1,100p' app/Http/Requests/StoreShippingRateRequest.php; sed -n '1,100p' app/Http/Requests/UpdateTaxSettingsRequest.php; sed -n '1,100p' app/Http/Requests/StoreThemeRequest.php; sed -n '1,100p' app/Http/Requests/UpdateThemeSettingsRequest.php

type: read

UpdateThemeSettingsRequest.php

stdout:
|---|---|---|
| `4000000000000002` | `card_declined` | "Your card was declined." |
| `4000000000009995` | `insufficient_funds` | "Your card has insufficient funds." |

---

### 2.4 Order Endpoints

#### GET /api/storefront/v1/orders/{orderNumber}

Retrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).

- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)
- **Rate limit:** `api.storefront` (120/min per IP)

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `token` | string | Yes | HMAC-signed access token for this order |

**Success response:** `200 OK`

```json
{
  "order_number": "#1001",
  "status": "paid",
  "financial_status": "paid",
  "fulfillment_status": "unfulfilled",
  "email": "customer@example.com",
  "currency": "EUR",
  "placed_at": "2026-02-11T10:10:00Z",
  "lines": [
    {
      "title_snapshot": "Classic T-Shirt",
      "variant_title": "Blue / Medium",
      "sku_snapshot": "TSH-BLU-M",
      "quantity": 2,
      "unit_price_amount": 2500,
      "total_amount": 5000
    }
  ],
  "totals": {
    "subtotal_amount": 5000,
    "discount_amount": 500,
    "shipping_amount": 500,
    "tax_amount": 950,
    "total_amount": 5950
  },
  "shipping_address": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "city": "Berlin",
    "country": "DE",
    "postal_code": "10115"
  },
  "fulfillments": []
}
```

**Error responses:**
- `401` - Invalid or missing token
- `404` - Order not found

---

### 2.5 Search Endpoints

#### GET /api/storefront/v1/search

Search products within the current store.

- **Authentication:** None
- **Rate limit:** `search` (30/min per IP)

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |
| `filters` | json | No | URL-encoded JSON object with filter facets. |
| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |

**Filters JSON schema:**

```json
{
  "collection_id": 5,
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;

class AnalyticsEvent extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'type', 'session_id', 'customer_id', 'client_event_id', 'payload', 'properties_json', 'occurred_at'];

    protected function casts(): array
    {
        return ['payload' => 'array', 'properties_json' => 'array', 'occurred_at' => 'datetime'];
    }

    protected static function booted(): void
    {
        static::saving(function (AnalyticsEvent $event): void {
            $event->properties_json = $event->payload ?? [];
        });
    }
}
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class TaxSettings extends Model
{
    use BelongsToStore;

    protected $primaryKey = 'store_id';

    public $incrementing = false;

    protected $fillable = ['store_id', 'mode', 'provider', 'prices_include_tax', 'config_json', 'default_rate_basis_points', 'rates_json', 'provider_config_json'];

    protected function casts(): array
    {
        return ['rates_json' => 'array', 'provider_config_json' => 'array', 'config_json' => 'array', 'prices_include_tax' => 'boolean'];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ShippingRate extends Model
{
    protected $fillable = ['shipping_zone_id', 'zone_id', 'name', 'type', 'price_amount', 'currency', 'config_json', 'is_active', 'estimated_days_min', 'estimated_days_max'];

    protected function casts(): array
    {
        return ['config_json' => 'array', 'is_active' => 'boolean'];
    }

    public function zone(): BelongsTo
    {
        return $this->belongsTo(ShippingZone::class, 'shipping_zone_id');
    }
}
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class ShippingZone extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'name', 'countries_json', 'regions_json'];

    protected function casts(): array
    {
        return ['countries_json' => 'array', 'regions_json' => 'array'];
    }

    public function rates(): HasMany
    {
        return $this->hasMany(ShippingRate::class);
    }

    public function matchesCountry(string $countryCode): bool
    {
        $countries = $this->countries_json ?? [];

        return $countries === [] || in_array(strtoupper($countryCode), array_map('strtoupper', $countries), true);
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class StoreShippingZoneRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return app()->bound('current_store') && Gate::allows('update', app('current_store'));
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['name' => ['required', 'string', 'max:255'], 'countries_json' => ['nullable', 'array'], 'regions_json' => ['nullable', 'array']];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class StoreShippingRateRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return app()->bound('current_store') && Gate::allows('update', app('current_store'));
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'price_amount' => ['required', 'integer', 'min:0'], 'currency' => ['required', 'size:3'], 'config_json' => ['nullable', 'array'], 'is_active' => ['nullable', 'boolean'], 'estimated_days_min' => ['nullable', 'integer', 'min:0'], 'estimated_days_max' => ['nullable', 'integer', 'min:0']];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class UpdateTaxSettingsRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return app()->bound('current_store') && Gate::allows('update', app('current_store'));
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['mode' => ['sometimes', 'string'], 'provider' => ['sometimes', 'nullable', 'string'], 'prices_include_tax' => ['sometimes', 'boolean'], 'default_rate_basis_points' => ['sometimes', 'integer', 'min:0', 'max:10000'], 'rates_json' => ['sometimes', 'array'], 'provider_config_json' => ['sometimes', 'array']];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class StoreThemeRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return app()->bound('current_store') && Gate::allows('create', \App\Models\Theme::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['name' => ['required', 'string', 'max:255'], 'version' => ['nullable', 'string', 'max:30'], 'settings' => ['nullable', 'array']];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class UpdateThemeSettingsRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return app()->bound('current_store') && Gate::allows('viewAny', \App\Models\Theme::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['settings' => ['required', 'array']];
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
|---|---|---|
| `4000000000000002` | `card_declined` | "Your card was declined." |
| `4000000000009995` | `insufficient_funds` | "Your card has insufficient funds." |

---

### 2.4 Order Endpoints

#### GET /api/storefront/v1/orders/{orderNumber}

Retrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).

- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)
- **Rate limit:** `api.storefront` (120/min per IP)

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `token` | string | Yes | HMAC-signed access token for this order |

**Success response:** `200 OK`

```json
{
  "order_number": "#1001",
  "status": "paid",
  "financial_status": "paid",
  "fulfillment_status": "unfulfilled",
  "email": "customer@example.com",
  "currency": "EUR",
  "placed_at": "2026-02-11T10:10:00Z",
  "lines": [
    {
      "title_snapshot": "Classic T-Shirt",
      "variant_title": "Blue / Medium",
      "sku_snapshot": "TSH-BLU-M",
      "quantity": 2,
      "unit_price_amount": 2500,
      "total_amount": 5000
    }
  ],
  "totals": {
    "subtotal_amount": 5000,
    "discount_amount": 500,
    "shipping_amount": 500,
    "tax_amount": 950,
    "total_amount": 5950
  },
  "shipping_address": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "city": "Berlin",
    "country": "DE",
    "postal_code": "10115"
  },
  "fulfillments": []
}
```

**Error responses:**
- `401` - Invalid or missing token
- `404` - Order not found

---

### 2.5 Search Endpoints

#### GET /api/storefront/v1/search

Search products within the current store.

- **Authentication:** None
- **Rate limit:** `search` (30/min per IP)

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query. Min: 1, max: 200 characters. |
| `filters` | json | No | URL-encoded JSON object with filter facets. |
| `sort` | string | No | One of: `relevance`, `price_asc`, `price_desc`, `newest`, `best_selling`. Default: `relevance`. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Min: 1, max: 50. Default: 24. |

**Filters JSON schema:**

```json
{
  "collection_id": 5,
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;

class AnalyticsEvent extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'type', 'session_id', 'customer_id', 'client_event_id', 'payload', 'properties_json', 'occurred_at'];

    protected function casts(): array
    {
        return ['payload' => 'array', 'properties_json' => 'array', 'occurred_at' => 'datetime'];
    }

    protected static function booted(): void
    {
        static::saving(function (AnalyticsEvent $event): void {
            $event->properties_json = $event->payload ?? [];
        });
    }
}
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class TaxSettings extends Model
{
    use BelongsToStore;

    protected $primaryKey = 'store_id';

    public $incrementing = false;

    protected $fillable = ['store_id', 'mode', 'provider', 'prices_include_tax', 'config_json', 'default_rate_basis_points', 'rates_json', 'provider_config_json'];

    protected function casts(): array
    {
        return ['rates_json' => 'array', 'provider_config_json' => 'array', 'config_json' => 'array', 'prices_include_tax' => 'boolean'];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ShippingRate extends Model
{
    protected $fillable = ['shipping_zone_id', 'zone_id', 'name', 'type', 'price_amount', 'currency', 'config_json', 'is_active', 'estimated_days_min', 'estimated_days_max'];

    protected function casts(): array
    {
        return ['config_json' => 'array', 'is_active' => 'boolean'];
    }

    public function zone(): BelongsTo
    {
        return $this->belongsTo(ShippingZone::class, 'shipping_zone_id');
    }
}
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class ShippingZone extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'name', 'countries_json', 'regions_json'];

    protected function casts(): array
    {
        return ['countries_json' => 'array', 'regions_json' => 'array'];
    }

    public function rates(): HasMany
    {
        return $this->hasMany(ShippingRate::class);
    }

    public function matchesCountry(string $countryCode): bool
    {
        $countries = $this->countries_json ?? [];

        return $countries === [] || in_array(strtoupper($countryCode), array_map('strtoupper', $countries), true);
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class StoreShippingZoneRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return app()->bound('current_store') && Gate::allows('update', app('current_store'));
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['name' => ['required', 'string', 'max:255'], 'countries_json' => ['nullable', 'array'], 'regions_json' => ['nullable', 'array']];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class StoreShippingRateRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return app()->bound('current_store') && Gate::allows('update', app('current_store'));
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'price_amount' => ['required', 'integer', 'min:0'], 'currency' => ['required', 'size:3'], 'config_json' => ['nullable', 'array'], 'is_active' => ['nullable', 'boolean'], 'estimated_days_min' => ['nullable', 'integer', 'min:0'], 'estimated_days_max' => ['nullable', 'integer', 'min:0']];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class UpdateTaxSettingsRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return app()->bound('current_store') && Gate::allows('update', app('current_store'));
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['mode' => ['sometimes', 'string'], 'provider' => ['sometimes', 'nullable', 'string'], 'prices_include_tax' => ['sometimes', 'boolean'], 'default_rate_basis_points' => ['sometimes', 'integer', 'min:0', 'max:10000'], 'rates_json' => ['sometimes', 'array'], 'provider_config_json' => ['sometimes', 'array']];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class StoreThemeRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return app()->bound('current_store') && Gate::allows('create', \App\Models\Theme::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['name' => ['required', 'string', 'max:255'], 'version' => ['nullable', 'string', 'max:30'], 'settings' => ['nullable', 'array']];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class UpdateThemeSettingsRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return app()->bound('current_store') && Gate::allows('viewAny', \App\Models\Theme::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['settings' => ['required', 'array']];
    }
}


exec

status: completed

input:
const r = await tools.exec_command({cmd:"php artisan make:migration add_platform_admin_and_payment_raw_json_columns --no-interaction","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":8000});
text(r.output);


command: php artisan make:migration add_platform_admin_and_payment_raw_json_columns --no-interaction

stdout: INFO  Migration [database/migrations/2026_08_21_003147_add_platform_admin_and_payment_raw_json_columns.php] created successfully.


text:
Script completed
Wall time 0.2 seconds
Output:

text: INFO  Migration [database/migrations/2026_08_21_003147_add_platform_admin_and_payment_raw_json_columns.php] created successfully.