text:
Script completed
Wall time 0.1 seconds
Output:

text:
---0---
<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Schedule::job(new \App\Jobs\CleanupAbandonedCarts)->daily();
Schedule::job(new \App\Jobs\ExpireAbandonedCheckouts)->everyFifteenMinutes();
Schedule::job(new \App\Jobs\AggregateAnalytics)->dailyAt('01:00');
Schedule::job(new \App\Jobs\CancelUnpaidBankTransferOrders)->dailyAt('02:00');

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
app/Jobs/ExpireAbandonedCheckouts.php
<?php

namespace App\Jobs;

use App\Enums\CheckoutStatus;
use App\Models\Checkout;
use App\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

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

    public function handle(InventoryService $inventory): void
    {
        Checkout::withoutGlobalScopes()->whereNotIn('status', [CheckoutStatus::Completed, CheckoutStatus::Expired])->where('expires_at', '<', now())->with('cart.lines.variant.inventory')->each(function (Checkout $checkout) use ($inventory): void {
            foreach ($checkout->cart->lines as $line) {
                if ($line->variant->inventory !== null && $checkout->status === CheckoutStatus::PaymentSelected) {
                    $inventory->release($line->variant->inventory, $line->quantity);
                }
            }

            $checkout->update(['status' => CheckoutStatus::Expired]);
        });
    }
}
app/Jobs/CleanupAbandonedCarts.php
<?php

namespace App\Jobs;

use App\Enums\CheckoutStatus;
use App\Models\Cart;
use App\Models\Checkout;
use App\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;

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

    public function handle(InventoryService $inventory): void
    {
        Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14))->each(function (Cart $cart) use ($inventory): void {
            DB::transaction(function () use ($cart, $inventory): void {
                $cart->load('lines.variant.inventory');
                $checkouts = Checkout::withoutGlobalScopes()
                    ->where('cart_id', $cart->getKey())
                    ->where('status', CheckoutStatus::PaymentSelected->value)
                    ->lockForUpdate()
                    ->get();

                foreach ($checkouts as $checkout) {
                    foreach ($cart->lines as $line) {
                        if ($line->variant?->inventory !== null) {
                            $inventory->release($line->variant->inventory, $line->quantity);
                        }
                    }

                    $checkout->update(['status' => CheckoutStatus::Expired]);
                }

                $cart->update(['status' => 'abandoned']);
            });
        });
    }
}
app/Jobs/AggregateAnalytics.php
<?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(),
                ],
            );
        }
    }
}
app/Jobs/CancelUnpaidBankTransferOrders.php
<?php

namespace App\Jobs;

use App\Enums\FinancialStatus;
use App\Models\Order;
use App\Services\OrderService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

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

    public function handle(OrderService $orders): void
    {
        $days = (int) config('shop.bank_transfer_expiry_days', 7);
        Order::withoutGlobalScopes()->where('payment_method', 'bank_transfer')->where('financial_status', FinancialStatus::Pending)->where('placed_at', '<', now()->subDays($days))->with('lines.variant.inventory')->each(fn (Order $order): mixed => $orders->cancel($order, 'Bank transfer payment expired.'));
    }
}
app/Jobs/ProcessMediaUpload.php
<?php

namespace App\Jobs;

use App\Models\ProductMedia;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;

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

    public int $tries = 3;

    /** @var array<int, int> */
    public array $backoff = [10, 30, 60];

    public function __construct(public ProductMedia $media) {}

    public function handle(): void
    {
        $media = $this->media->fresh();
        $disk = Storage::disk('public');
        $sourceKey = $media?->storage_key ?: $media?->path;

        if ($media === null || $sourceKey === null || ! $disk->exists($sourceKey)) {
            throw new RuntimeException('The uploaded media file is not available yet.');
        }

        $contents = $disk->get($sourceKey);
        $mimeType = $media->mime_type ?: $disk->mimeType($sourceKey) ?: 'application/octet-stream';
        $metadata = $media->metadata ?? [];
        $width = null;
        $height = null;
        $variants = ['original' => $sourceKey];

        if (str_starts_with($mimeType, 'image/')) {
            $dimensions = @getimagesizefromstring($contents);
            if ($dimensions === false) {
                throw new RuntimeException('The uploaded image could not be decoded.');
            }
            [$width, $height] = $dimensions;
            $variants = $this->writeImageVariants($disk, $sourceKey, $contents, $mimeType, $width, $height);
        }

        $metadata['variants'] = $variants;
        $metadata['processed_at'] = now()->toIso8601String();
        $media->update([
            'status' => 'ready',
            'width' => $width,
            'height' => $height,
            'mime_type' => $mimeType,
            'byte_size' => strlen($contents),
            'checksum' => hash('sha256', $contents),
            'url' => $disk->url($sourceKey),
            'metadata' => $metadata,
        ]);
    }

    public function failed(Throwable $exception): void
    {
        $media = $this->media->fresh();
        $metadata = $media?->metadata ?? [];
        $metadata['error'] = $exception->getMessage();
        $metadata['failed_at'] = now()->toIso8601String();
        $media?->update(['status' => 'failed', 'metadata' => $metadata]);
    }

    /**
     * @return array<string, string>
     */
    private function writeImageVariants(object $disk, string $sourceKey, string $contents, string $mimeType, int $width, int $height): array
    {
        $variants = ['original' => $sourceKey];
        $directory = trim(pathinfo($sourceKey, PATHINFO_DIRNAME), '.');
        $extension = strtolower(pathinfo($sourceKey, PATHINFO_EXTENSION));

        foreach (['thumbnail' => 320, 'medium' => 800, 'large' => 1600] as $name => $maximum) {
            if ($width <= $maximum && $height <= $maximum) {
                $variants[$name] = $sourceKey;

                continue;
            }

            $resized = $this->resize($contents, $mimeType, $width, $height, $maximum);
            if ($resized === null) {
                $variants[$name] = $sourceKey;

                continue;
            }

            $key = $directory.'/'.$name.'.'.$extension;
            $disk->put($key, $resized);
            $variants[$name] = $key;
        }

        return $variants;
    }

    private function resize(string $contents, string $mimeType, int $width, int $height, int $maximum): ?string
    {
        $source = @imagecreatefromstring($contents);
        if ($source === false) {
            return null;
        }

        $scale = min($maximum / $width, $maximum / $height);
        $targetWidth = max(1, (int) round($width * $scale));
        $targetHeight = max(1, (int) round($height * $scale));
        $target = imagecreatetruecolor($targetWidth, $targetHeight);
        imagealphablending($target, false);
        imagesavealpha($target, true);
        imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
        ob_start();

        $written = match ($mimeType) {
            'image/png' => imagepng($target, null, 8),
            'image/webp' => function_exists('imagewebp') ? imagewebp($target, null, 85) : false,
            default => imagejpeg($target, null, 85),
        };

        $result = $written ? ob_get_clean() : false;
        imagedestroy($source);
        imagedestroy($target);

        return is_string($result) ? $result : null;
    }
}
app/Jobs/DeliverWebhook.php
<?php

namespace App\Jobs;

use App\Models\WebhookDelivery;
use App\Services\WebhookService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Throwable;

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

    public int $tries = 6;

    public function __construct(public WebhookDelivery $delivery) {}

    /** @return array<int, int> */
    public function backoff(): array
    {
        return [60, 300, 1800, 7200, 43200];
    }

    public function handle(WebhookService $webhooks): void
    {
        $this->delivery->load('subscription');
        $payload = json_encode($this->delivery->payload, JSON_THROW_ON_ERROR);
        $subscription = $this->delivery->subscription;
        $timestamp = (string) now()->timestamp;
        $response = Http::withHeaders([
            'Content-Type' => 'application/json',
            'X-Platform-Signature' => $webhooks->sign($timestamp.'.'.$payload, $subscription->signing_secret_encrypted),
            'X-Platform-Event' => $this->delivery->event,
            'X-Platform-Delivery-Id' => (string) $this->delivery->getKey(),
            'X-Platform-Timestamp' => $timestamp,
        ])->timeout(10)->post($subscription->target_url, $this->delivery->payload);

        $this->delivery->increment('attempts');
        $this->delivery->increment('attempt_count');
        $this->delivery->update([
            'response_status' => $response->status(),
            'response_code' => $response->status(),
            'response_body' => mb_substr($response->body(), 0, 10000),
            'response_body_snippet' => mb_substr($response->body(), 0, 1000),
            'last_attempt_at' => now(),
        ]);

        if ($response->successful()) {
            $subscription->update(['consecutive_failures' => 0]);
            $this->delivery->update(['status' => 'delivered', 'delivered_at' => now(), 'next_attempt_at' => null]);

            return;
        }

        $subscription->increment('consecutive_failures');

        if ($subscription->fresh()->consecutive_failures >= 5) {
            $subscription->update(['status' => 'paused']);
        }

        throw new \RuntimeException('Webhook delivery failed with HTTP '.$response->status());
    }

    public function failed(?Throwable $exception): void
    {
        $this->delivery->update(['status' => 'failed', 'next_attempt_at' => null]);
    }
}

text:
---1---
find: app/Listeners: No such file or directory
app/Events/FulfillmentDelivered.php
app/Events/FulfillmentShipped.php
app/Events/OrderCancelled.php
app/Events/OrderCreated.php
app/Events/OrderFulfilled.php
app/Events/OrderPaid.php
app/Events/OrderRefunded.php
app/Events/ProductStatusChanged.php
app/Observers/ProductObserver.php
app/Policies/CollectionPolicy.php
app/Policies/CustomerPolicy.php
app/Policies/DiscountPolicy.php
app/Policies/FulfillmentPolicy.php
app/Policies/OrderPolicy.php
app/Policies/PagePolicy.php
app/Policies/ProductPolicy.php
app/Policies/RefundPolicy.php
app/Policies/StorePolicy.php
app/Policies/ThemePolicy.php
app/Services/AnalyticsService.php
app/Services/AuditLogger.php
app/Services/CartService.php
app/Services/CheckoutService.php
app/Services/DiscountService.php
app/Services/FulfillmentService.php
app/Services/InventoryService.php
app/Services/MockPaymentProvider.php
app/Services/OrderService.php
app/Services/PaymentProvider.php
app/Services/PaymentService.php
app/Services/PricingEngine.php
app/Services/ProductService.php
app/Services/RefundService.php
app/Services/SearchService.php
app/Services/ShippingCalculator.php
app/Services/Tax/ManualTaxProvider.php
app/Services/Tax/StripeTaxProvider.php
app/Services/TaxCalculator.php
app/Services/VariantMatrixService.php
app/Services/WebhookService.php

text:
---2---
<?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\Livewire\Admin\Analytics;

use App\Models\AnalyticsDaily;
use Illuminate\Contracts\View\View;
use Livewire\Component;

class Index extends Component
{
    public string $range = '30';

    public function render(): View
    {
        $days = AnalyticsDaily::query()->where('date', '>=', now()->subDays((int) $this->range - 1)->toDateString())->orderBy('date')->get();
        $summary = [
            'visits' => (int) $days->sum('visits_count'),
            'orders' => (int) $days->sum('orders_count'),
            'revenue' => (int) $days->sum('revenue_amount'),
            'add_to_cart' => (int) $days->sum('add_to_cart_count'),
            'checkout_started' => (int) $days->sum('checkout_started_count'),
            'checkout_completed' => (int) $days->sum('checkout_completed_count'),
        ];

        return view('livewire.admin.analytics.index', compact('days', 'summary'))->layout('layouts.admin');
    }
}

Insights

Analytics

Monitor traffic, conversion, orders, and revenue.

<flux:select wire:model.live="range" aria-label="Analytics date range"><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></flux:select>

@foreach([['Visitors', $summary['visits']], ['Orders', $summary['orders']], ['Revenue', '€'.number_format($summary['revenue'] / 100, 2)], ['Conversion', $summary['visits'] > 0 ? number_format($summary['orders'] / $summary['visits'] * 100, 2).'%' : '0%']] as [$label, $value])

{{ $label }}

{{ $value }}

@endforeach

Conversion funnel

Visits

{{ number_format($summary['visits']) }}

Add to cart

{{ number_format($summary['add_to_cart']) }}

Checkout started

{{ number_format($summary['checkout_started']) }}

Checkout completed

{{ number_format($summary['checkout_completed']) }}

Daily performance

Date	Visits	Orders	Revenue

@forelse($days->reverse() as $day)
{{ $day->date->toDateString() }}	{{ $day->visits_count }}	{{ $day->orders_count }}	€{{ number_format($day->revenue_amount / 100, 2) }}
@empty
No analytics data for this period.
@endforelse

text:
---3---
<?php

namespace App\Services;

use App\Contracts\PaymentProvider;
use App\Enums\CheckoutStatus;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Models\Checkout;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;

class PaymentService
{
    public function __construct(private readonly PaymentProvider $provider, private readonly InventoryService $inventory, private readonly OrderService $orders, private readonly PricingEngine $pricing) {}

    public function pay(Checkout $checkout, PaymentMethod $method, array $details = []): ?Order
    {
        return DB::transaction(function () use ($checkout, $method, $details): ?Order {
            $existing = Order::withoutGlobalScopes()->where('checkout_id', $checkout->getKey())->first();

            if ($existing !== null) {
                return $existing->load(['lines', 'payments']);
            }

            if ($checkout->status !== CheckoutStatus::PaymentSelected) {
                throw new \LogicException('Checkout must have a selected payment method before payment.');
            }

            $checkout->update(['payment_method' => $method]);
            $this->pricing->calculate($checkout->refresh());
            $checkout->load('cart.lines.variant.inventory');

            $result = $this->provider->charge($checkout, $method, $details);

            if ($result->status === PaymentStatus::Failed) {
                foreach ($checkout->cart->lines as $line) {
                    if ($line->variant->inventory !== null) {
                        $this->inventory->release($line->variant->inventory, $line->quantity);
                    }
                }

                $checkout->update(['status' => CheckoutStatus::ShippingSelected]);

                return null;
            }

            $order = $this->orders->createFromCheckout($checkout, $result);
            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])]);

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

            return $order->refresh()->load(['lines', 'payments']);
        });
    }
}
<?php

namespace App\Services;

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Enums\PaymentStatus;
use App\Events\OrderCancelled;
use App\Events\OrderCreated;
use App\Events\OrderPaid;
use App\Models\Checkout;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Store;
use App\ValueObjects\PaymentResult;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class OrderService
{
    public function __construct(private readonly InventoryService $inventory, private readonly AuditLogger $audit) {}

    public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentResult = null): Order
    {
        return DB::transaction(function () use ($checkout, $paymentResult): Order {
            $checkout->load(['cart.lines.variant.product', 'cart.lines.variant.inventory', 'shippingRate']);
            $existing = Order::withoutGlobalScopes()->where('checkout_id', $checkout->getKey())->first();

            if ($existing !== null) {
                return $existing->load(['lines', 'payments']);
            }

            $totals = $checkout->totals_json ?? ['subtotal' => 0, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 0, 'currency' => $checkout->cart->currency];
            $discount = $checkout->discount_code === null ? null : Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->first();
            $status = $paymentResult?->status === PaymentStatus::Captured ? FinancialStatus::Paid : FinancialStatus::Pending;
            $taxByLine = $this->allocateTaxLines($checkout->cart->lines, $totals['tax_lines'] ?? []);
            $order = Order::withoutGlobalScopes()->create([
                'store_id' => $checkout->store_id,
                'customer_id' => $checkout->customer_id,
                'checkout_id' => $checkout->getKey(),
                'order_number' => $this->generateOrderNumber(Store::findOrFail($checkout->store_id)),
                'currency' => $totals['currency'],
                'status' => $status === FinancialStatus::Paid ? OrderStatus::Paid : OrderStatus::Pending,
                'financial_status' => $status,
                'fulfillment_status' => 'unfulfilled',
                'payment_method' => $checkout->payment_method,
                'email' => $checkout->email,
                'shipping_address_json' => $checkout->shipping_address_json,
                'billing_address_json' => $checkout->billing_address_json,
                'subtotal_amount' => $totals['subtotal'],
                'discount_amount' => $totals['discount'] ?? 0,
                'shipping_amount' => $totals['shipping'] ?? 0,
                'tax_amount' => $totals['tax'] ?? 0,
                'total_amount' => $totals['total'],
                'placed_at' => now(),
            ]);

            foreach ($checkout->cart->lines as $line) {
                $order->lines()->create([
                    'product_id' => $line->variant->product_id,
                    'variant_id' => $line->variant_id,
                    'product_title' => $line->variant->product->title,
                    'title_snapshot' => $line->variant->product->title.' · '.$line->variant->title,
                    'variant_title' => $line->variant->title,
                    'sku' => $line->variant->sku,
                    'sku_snapshot' => $line->variant->sku,
                    'quantity' => $line->quantity,
                    'unit_price_amount' => $line->unit_price_amount,
                    'line_subtotal_amount' => $line->line_subtotal_amount,
                    'line_discount_amount' => $line->line_discount_amount,
                    'line_total_amount' => $line->line_total_amount,
                    'tax_lines_json' => $taxByLine[$line->getKey()] ?? [],
                    'discount_allocations_json' => $discount === null || $line->line_discount_amount < 1 ? [] : [['discount_id' => $discount->getKey(), 'amount' => $line->line_discount_amount]],
                ]);

                if ($paymentResult?->status === PaymentStatus::Captured && $line->variant->inventory !== null) {
                    $this->inventory->commit($line->variant->inventory, $line->quantity);
                }
            }

            $checkout->update(['status' => 'completed']);
            $checkout->cart->update(['status' => 'converted']);
            OrderCreated::dispatch($order);
            $this->audit->record('order.created', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]);
            $order->load(['lines', 'payments']);

            if ($status === FinancialStatus::Paid) {
                OrderPaid::dispatch($order);
                $this->audit->record('order.paid', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]);

                if ($checkout->cart->lines->every(fn ($line): bool => ! $line->variant->requires_shipping)) {
                    $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'fulfilled_at' => now(), 'delivered_at' => now()]);

                    foreach ($order->lines as $line) {
                        $fulfillment->lines()->create(['order_line_id' => $line->getKey(), 'quantity' => $line->quantity]);
                    }

                    $order->update(['status' => OrderStatus::Fulfilled, 'fulfillment_status' => FulfillmentStatus::Fulfilled]);
                }
            }

            return $order;
        });
    }

    public function generateOrderNumber(Store $store): string
    {
        $lastNumber = Order::withoutGlobalScopes()->where('store_id', $store->getKey())->selectRaw("max(cast(replace(order_number, '#', '') as integer)) as value")->value('value');

        return (string) config('shop.order_prefix', '#').(max(1000, (int) $lastNumber) + 1);
    }

    public function cancel(Order $order, string $reason): void
    {
        if ($order->fulfillment_status !== FulfillmentStatus::Unfulfilled) {
            throw new \LogicException('Fulfilled orders cannot be cancelled.');
        }

        DB::transaction(function () use ($order, $reason): void {
            $wasPending = $order->financial_status === FinancialStatus::Pending;
            $order->load('lines.variant.inventory')->update(['status' => OrderStatus::Cancelled, 'financial_status' => $order->financial_status === FinancialStatus::Pending ? FinancialStatus::Voided : $order->financial_status, 'metadata' => array_merge($order->metadata ?? [], ['cancellation_reason' => $reason])]);

            foreach ($order->lines as $line) {
                if ($line->variant?->inventory !== null && $wasPending) {
                    $this->inventory->release($line->variant->inventory, $line->quantity);
                }
            }

            $order->payments()->whereIn('status', [PaymentStatus::Pending, PaymentStatus::Authorized])->update(['status' => PaymentStatus::Failed]);

            OrderCancelled::dispatch($order->refresh());
            $this->audit->record('order.cancelled', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number, 'reason' => $reason]);
        });
    }

    public function confirmPayment(Order $order): void
    {
        if ($order->payment_method !== 'bank_transfer' || $order->financial_status !== FinancialStatus::Pending) {
            throw new \LogicException('Only pending bank transfer orders can be confirmed.');
        }

        DB::transaction(function () use ($order): void {
            $order->load('lines.variant.inventory')->update(['financial_status' => FinancialStatus::Paid, 'status' => OrderStatus::Paid]);

            foreach ($order->lines as $line) {
                if ($line->variant?->inventory !== null) {
                    $this->inventory->commit($line->variant->inventory, $line->quantity);
                }
            }

            $order->payments()->where('status', PaymentStatus::Pending)->update(['status' => PaymentStatus::Captured]);
            OrderPaid::dispatch($order->refresh());
            $this->audit->record('order.paid', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]);

            if ($order->lines->every(fn ($line): bool => ! $line->variant?->requires_shipping)) {
                $this->autoFulfillDigitalOrder($order->refresh());
            }
        });
    }

    private function autoFulfillDigitalOrder(Order $order): void
    {
        if ($order->fulfillments()->exists()) {
            return;
        }

        $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'fulfilled_at' => now(), 'delivered_at' => now()]);
        foreach ($order->lines as $line) {
            $fulfillment->lines()->create(['order_line_id' => $line->getKey(), 'quantity' => $line->quantity]);
        }
        $order->update(['status' => OrderStatus::Fulfilled, 'fulfillment_status' => FulfillmentStatus::Fulfilled]);
    }

    /**
     * @param  \Illuminate\Support\Collection<int, \App\Models\CartLine>  $lines
     * @param  array<int, array{title?: string, rate?: int, amount?: int}>  $taxLines
     * @return array<int, array<int, array<string, mixed>>>
     */
    private function allocateTaxLines(Collection $lines, array $taxLines): array
    {
        $base = max(1, (int) $lines->sum('line_total_amount'));
        $allocations = $lines->mapWithKeys(fn ($line): array => [$line->getKey() => []])->all();

        foreach ($taxLines as $taxLine) {
            $remaining = (int) ($taxLine['amount'] ?? 0);
            $lineCount = $lines->count();

            foreach ($lines->values() as $index => $line) {
                $amount = $index === $lineCount - 1
                    ? $remaining
                    : intdiv((int) ($taxLine['amount'] ?? 0) * (int) $line->line_total_amount, $base);
                $remaining -= $amount;

                if ($amount > 0) {
                    $allocations[$line->getKey()][] = [...$taxLine, 'amount' => $amount];
                }
            }
        }

        return $allocations;
    }
}


exec

status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,320p' app/Services/WebhookService.php && for f in app/Events/*.php app/Observers/ProductObserver.php app/Providers/AppServiceProvider.php; do echo ---$f; sed -n '1,260p' $f; done", workdir:"/Users/wesner/Herd/shop", yield_time_ms:10000, max_output_tokens:40000});
text(r.output);


type: CommandExecution

command: sed -n '1,320p' app/Services/WebhookService.php && for f in app/Events/*.php app/Observers/ProductObserver.php app/Providers/AppServiceProvider.php; do echo ---$f; sed -n '1,260p' $f; done

type: unknown

status: completed

stdout:
<?php

namespace App\Services;

use App\Jobs\DeliverWebhook;
use App\Models\Store;
use App\Models\WebhookSubscription;

class WebhookService
{
    public function dispatch(Store $store, string $eventType, array $payload): void
    {
        WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where(function ($query) use ($eventType): void {
                $query->where('event', $eventType)->orWhere('event_type', $eventType);
            })
            ->where('status', 'active')
            ->get()
            ->each(function (WebhookSubscription $subscription) use ($eventType, $payload): void {
                $delivery = $subscription->deliveries()->create(['event' => $eventType, 'event_id' => (string) str()->uuid(), 'payload' => $payload, 'attempts' => 0, 'attempt_count' => 0, 'next_attempt_at' => now()]);
                DeliverWebhook::dispatch($delivery);
            });
    }

    public function sign(string $payload, string $secret): string
    {
        return hash_hmac('sha256', $payload, $secret);
    }

    public function verify(string $payload, string $signature, string $secret): bool
    {
        return hash_equals($this->sign($payload, $secret), $signature);
    }
}
---app/Events/FulfillmentDelivered.php
<?php

namespace App\Events;

use App\Models\Fulfillment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class FulfillmentDelivered
{
    use Dispatchable, SerializesModels;

    public function __construct(public Fulfillment $fulfillment) {}
}
---app/Events/FulfillmentShipped.php
<?php

namespace App\Events;

use App\Models\Fulfillment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class FulfillmentShipped
{
    use Dispatchable, SerializesModels;

    public function __construct(public Fulfillment $fulfillment) {}
}
---app/Events/OrderCancelled.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderCancelled
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
---app/Events/OrderCreated.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderCreated
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
---app/Events/OrderFulfilled.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderFulfilled
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
---app/Events/OrderPaid.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderPaid
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
---app/Events/OrderRefunded.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderRefunded
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
---app/Events/ProductStatusChanged.php
<?php

namespace App\Events;

use App\Enums\ProductStatus;
use App\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductStatusChanged
{
    use Dispatchable, SerializesModels;

    public function __construct(public Product $product, public ProductStatus $from, public ProductStatus $to) {}
}
---app/Observers/ProductObserver.php
<?php

namespace App\Observers;

use App\Models\Product;
use App\Services\SearchService;

class ProductObserver
{
    public function created(Product $product): void
    {
        app(SearchService::class)->syncProduct($product);
    }

    public function updated(Product $product): void
    {
        app(SearchService::class)->syncProduct($product);
    }

    public function deleted(Product $product): void
    {
        app(SearchService::class)->removeProduct($product->getKey());
    }
}
---app/Providers/AppServiceProvider.php
<?php

namespace App\Providers;

use App\Auth\CustomerUserProvider;
use App\Auth\StoreScopedPasswordBrokerManager;
use App\Contracts\PaymentProvider as PaymentProviderContract;
use App\Contracts\TaxProvider;
use App\Models\Product;
use App\Observers\ProductObserver;
use App\Services\MockPaymentProvider;
use App\Services\Tax\ManualTaxProvider;
use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Livewire\Livewire;
use Livewire\Mechanisms\HandleRequests\EndpointResolver;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->bind(PaymentProviderContract::class, MockPaymentProvider::class);
        $this->app->bind(TaxProvider::class, ManualTaxProvider::class);
        Auth::provider('customer', fn ($app, array $config): CustomerUserProvider => new CustomerUserProvider($app['hash'], $config['model']));
        $this->app->extend('auth.password', fn ($manager, $app): StoreScopedPasswordBrokerManager => new StoreScopedPasswordBrokerManager($app));
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureDefaults();
        Model::preventLazyLoading(! app()->isProduction());
        Product::observe(ProductObserver::class);
        Livewire::setUpdateRoute(function ($handle): mixed {
            return Route::post(EndpointResolver::updatePath(), $handle)
                ->middleware(['web', 'store.resolve'])
                ->name('shop.livewire.update');
        });
        RateLimiter::for('login', fn (Request $request): Limit => Limit::perMinute(5)->by($request->ip()));
        RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by($request->ip()));
        RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->getAuthIdentifier() ?? $request->ip()));
        RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip()));
        RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip()));
        RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip()));
        RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(100)->by($request->ip()));
    }

    /**
     * Configure default behaviors for production-ready applications.
     */
    protected function configureDefaults(): void
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null
        );
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Services;

use App\Jobs\DeliverWebhook;
use App\Models\Store;
use App\Models\WebhookSubscription;

class WebhookService
{
    public function dispatch(Store $store, string $eventType, array $payload): void
    {
        WebhookSubscription::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where(function ($query) use ($eventType): void {
                $query->where('event', $eventType)->orWhere('event_type', $eventType);
            })
            ->where('status', 'active')
            ->get()
            ->each(function (WebhookSubscription $subscription) use ($eventType, $payload): void {
                $delivery = $subscription->deliveries()->create(['event' => $eventType, 'event_id' => (string) str()->uuid(), 'payload' => $payload, 'attempts' => 0, 'attempt_count' => 0, 'next_attempt_at' => now()]);
                DeliverWebhook::dispatch($delivery);
            });
    }

    public function sign(string $payload, string $secret): string
    {
        return hash_hmac('sha256', $payload, $secret);
    }

    public function verify(string $payload, string $signature, string $secret): bool
    {
        return hash_equals($this->sign($payload, $secret), $signature);
    }
}
---app/Events/FulfillmentDelivered.php
<?php

namespace App\Events;

use App\Models\Fulfillment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class FulfillmentDelivered
{
    use Dispatchable, SerializesModels;

    public function __construct(public Fulfillment $fulfillment) {}
}
---app/Events/FulfillmentShipped.php
<?php

namespace App\Events;

use App\Models\Fulfillment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class FulfillmentShipped
{
    use Dispatchable, SerializesModels;

    public function __construct(public Fulfillment $fulfillment) {}
}
---app/Events/OrderCancelled.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderCancelled
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
---app/Events/OrderCreated.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderCreated
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
---app/Events/OrderFulfilled.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderFulfilled
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
---app/Events/OrderPaid.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderPaid
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
---app/Events/OrderRefunded.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderRefunded
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
---app/Events/ProductStatusChanged.php
<?php

namespace App\Events;

use App\Enums\ProductStatus;
use App\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductStatusChanged
{
    use Dispatchable, SerializesModels;

    public function __construct(public Product $product, public ProductStatus $from, public ProductStatus $to) {}
}
---app/Observers/ProductObserver.php
<?php

namespace App\Observers;

use App\Models\Product;
use App\Services\SearchService;

class ProductObserver
{
    public function created(Product $product): void
    {
        app(SearchService::class)->syncProduct($product);
    }

    public function updated(Product $product): void
    {
        app(SearchService::class)->syncProduct($product);
    }

    public function deleted(Product $product): void
    {
        app(SearchService::class)->removeProduct($product->getKey());
    }
}
---app/Providers/AppServiceProvider.php
<?php

namespace App\Providers;

use App\Auth\CustomerUserProvider;
use App\Auth\StoreScopedPasswordBrokerManager;
use App\Contracts\PaymentProvider as PaymentProviderContract;
use App\Contracts\TaxProvider;
use App\Models\Product;
use App\Observers\ProductObserver;
use App\Services\MockPaymentProvider;
use App\Services\Tax\ManualTaxProvider;
use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Livewire\Livewire;
use Livewire\Mechanisms\HandleRequests\EndpointResolver;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->bind(PaymentProviderContract::class, MockPaymentProvider::class);
        $this->app->bind(TaxProvider::class, ManualTaxProvider::class);
        Auth::provider('customer', fn ($app, array $config): CustomerUserProvider => new CustomerUserProvider($app['hash'], $config['model']));
        $this->app->extend('auth.password', fn ($manager, $app): StoreScopedPasswordBrokerManager => new StoreScopedPasswordBrokerManager($app));
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureDefaults();
        Model::preventLazyLoading(! app()->isProduction());
        Product::observe(ProductObserver::class);
        Livewire::setUpdateRoute(function ($handle): mixed {
            return Route::post(EndpointResolver::updatePath(), $handle)
                ->middleware(['web', 'store.resolve'])
                ->name('shop.livewire.update');
        });
        RateLimiter::for('login', fn (Request $request): Limit => Limit::perMinute(5)->by($request->ip()));
        RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by($request->ip()));
        RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->getAuthIdentifier() ?? $request->ip()));
        RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip()));
        RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip()));
        RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip()));
        RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(100)->by($request->ip()));
    }

    /**
     * Configure default behaviors for production-ready applications.
     */
    protected function configureDefaults(): void
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null
        );
    }
}


status: completed

input:
const r = await tools.exec_command({cmd:"rg -n \"export|Export|oauth|apps/v1|app API|presign\" app database routes specs/02-API-ROUTES.md | head -300 && sed -n '2750,2895p' specs/02-API-ROUTES.md && sed -n '2900,3005p' specs/02-API-ROUTES.md", workdir:"/Users/wesner/Herd/shop", yield_time_ms:10000, max_output_tokens:30000});
text(r.output);


command: rg -n "export|Export|oauth|apps/v1|app API|presign" app database routes specs/02-API-ROUTES.md | head -300 && sed -n '2750,2895p' specs/02-API-ROUTES.md && sed -n '2900,3005p' specs/02-API-ROUTES.md

type: search

cmd: rg -n 'export|Export|oauth|apps/v1|app API|presign' app database routes specs/02-API-ROUTES.md

query: export|Export|oauth|apps/v1|app API|presign

path: app

type: read

stdout:
specs/02-API-ROUTES.md:136:| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |
specs/02-API-ROUTES.md:1404:| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1694:#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload
specs/02-API-ROUTES.md:1696:Generate a presigned URL for uploading product media directly to object storage (S3).
specs/02-API-ROUTES.md:1718:  "upload_url": "https://s3.example.com/...presigned...",
specs/02-API-ROUTES.md:2764:### 3.11 Analytics and Exports
specs/02-API-ROUTES.md:2771:| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2772:| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2834:#### POST /api/admin/v1/stores/{storeId}/exports/orders
specs/02-API-ROUTES.md:2836:Queue an order export job. The export runs asynchronously and produces a downloadable CSV.
specs/02-API-ROUTES.md:2860:  "export_id": 1,
specs/02-API-ROUTES.md:2868:#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}
specs/02-API-ROUTES.md:2870:Check export status and get download URL.
specs/02-API-ROUTES.md:2881:    "download_url": "https://s3.example.com/...presigned...",
specs/02-API-ROUTES.md:2889:**Export status values:** `queued`, `processing`, `completed`, `failed`.
specs/02-API-ROUTES.md:2893:- `404` - Export not found
specs/02-API-ROUTES.md:2911:### GET /oauth/authorize
specs/02-API-ROUTES.md:2931:### POST /oauth/token
specs/02-API-ROUTES.md:2983:| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
specs/02-API-ROUTES.md:2984:| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
specs/02-API-ROUTES.md:2985:| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |
specs/02-API-ROUTES.md:3173:- **App API:** Versioned via URL path (`/api/apps/v1/`).
routes/api.php:49:    Route::post('products/{productId}/media/presign-upload', [PlatformController::class, 'presignMediaUpload'])->middleware('role.check:owner,admin,staff');
routes/web.php:102:Route::get('/oauth/authorize', fn (): \Illuminate\Http\JsonResponse => response()->json(['message' => 'OAuth is not enabled in this deployment.'], 501))->middleware('auth')->name('oauth.authorize');
routes/web.php:103:Route::post('/oauth/token', fn (): \Illuminate\Http\JsonResponse => response()->json(['message' => 'OAuth is not enabled in this deployment.'], 501))->name('oauth.token');
app/Http/Controllers/Api/PlatformController.php:82:    public function presignMediaUpload(PresignMediaUploadRequest $request, int $storeId, int $productId): JsonResponse
app/Http/Controllers/Api/PlatformController.php:107:                // Local development disks do not support presigned uploads.
database/migrations/2026_08_20_000000_create_shop_schema.php:528:        Schema::create('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:538:        Schema::create('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:540:            $table->foreignId('oauth_client_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php:578:            'webhook_deliveries', 'webhook_subscriptions', 'oauth_tokens', 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:59:        Schema::table('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:60:            if (! Schema::hasColumn('oauth_clients', 'app_id')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:64:            if (! Schema::hasColumn('oauth_clients', 'redirect_uris_json')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:69:        Schema::table('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:70:            if (! Schema::hasColumn('oauth_tokens', 'installation_id')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:74:            if (! Schema::hasColumn('oauth_tokens', 'access_token_hash')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:78:            if (! Schema::hasColumn('oauth_tokens', 'refresh_token_hash')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:108:            'app_id' => 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:109:            'redirect_uris_json' => 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:110:            'installation_id' => 'oauth_tokens',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:111:            'access_token_hash' => 'oauth_tokens',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:112:            'refresh_token_hash' => 'oauth_tokens',
{
  "data": {
    "store_id": 1,
    "index_status": "ready",
    "last_reindex_at": "2026-02-11T08:00:00Z",
    "last_reindex_duration_seconds": 45,
    "documents_count": 342,
    "pending_updates": 0
  }
}
```

---

### 3.11 Analytics and Exports

#### Admin Analytics Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/analytics/summary

Get analytics summary for a date range.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `from` | string (YYYY-MM-DD) | Yes | Start date. |
| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |
| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |

**Success response:** `200 OK`

```json
{
  "data": {
    "period": {
      "from": "2026-02-01",
      "to": "2026-02-11"
    },
    "summary": {
      "orders_count": 156,
      "revenue_amount": 1250000,
      "aov_amount": 8012,
      "visits_count": 4520,
      "add_to_cart_count": 890,
      "checkout_started_count": 420,
      "conversion_rate": 0.0345,
      "currency": "EUR"
    },
    "daily": [
      {
        "date": "2026-02-01",
        "orders_count": 12,
        "revenue_amount": 95000,
        "aov_amount": 7917,
        "visits_count": 380,
        "add_to_cart_count": 72,
        "checkout_started_count": 35
      }
    ],
    "top_products": [
      {
        "product_id": 10,
        "title": "Classic T-Shirt",
        "units_sold": 45,
        "revenue_amount": 112500
      }
    ]
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid date range

---

#### POST /api/admin/v1/stores/{storeId}/exports/orders

Queue an order export job. The export runs asynchronously and produces a downloadable CSV.

**Request body:**

```json
{
  "format": "csv",
  "filters": {
    "status": "paid",
    "created_after": "2026-01-01T00:00:00Z",
    "created_before": "2026-02-11T23:59:59Z"
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `format` | string | No | One of: `csv`. Default: `csv`. |
| `filters` | object | No | Same filter options as GET /orders. |

**Success response:** `202 Accepted`

```json
{
  "export_id": 1,
  "status": "queued",
  "created_at": "2026-02-11T10:00:00Z"
}
```

---

#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}

Check export status and get download URL.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 1,
    "status": "completed",
    "format": "csv",
    "row_count": 156,
    "download_url": "https://s3.example.com/...presigned...",
    "download_expires_at": "2026-02-11T11:00:00Z",
    "created_at": "2026-02-11T10:00:00Z",
    "completed_at": "2026-02-11T10:01:30Z"
  }
}
```

**Export status values:** `queued`, `processing`, `completed`, `failed`.

**Error responses:**
- `403` - Insufficient permissions
- `404` - Export not found

---

---

## 5. OAuth Endpoints (App Ecosystem)

> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.

These endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.

---

### GET /oauth/authorize

Display the authorization prompt to the merchant.

- **Middleware:** `web`, `auth`

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | string | Yes | The app's OAuth client ID |
| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |
| `response_type` | string | Yes | Must be `code` |
| `scope` | string | Yes | Space-separated list of requested scopes |
| `state` | string | Yes | CSRF token for the client |

**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.

---

### POST /oauth/token

Exchange an authorization code for access and refresh tokens.

- **Middleware:** None (public endpoint)

**Authorization code exchange request:**

```json
{
  "grant_type": "authorization_code",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "redirect_uri": "https://myapp.example.com/callback",
  "code": "auth_code_abc123"
}
```

**Success response:** `200 OK`

```json
{
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "eyJ...",
  "refresh_token": "def..."
}
```

**Error responses:**
- `400` - Invalid grant (code already used, expired, etc.)
- `401` - Invalid client credentials

**Refresh token request:**

```json
{
  "grant_type": "refresh_token",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "refresh_token": "def..."
}
```

---

### App API Endpoints

Third-party apps use OAuth tokens to access store data via scoped endpoints.

| Method | Path | Scope Required | Description |
|--------|------|----------------|-------------|
| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |

Token scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.

---

## 6. Middleware Stack

### 6.1 Route Group Middleware

#### Web (default)

Applied to all web routes via the `web` middleware group:

| Middleware | Purpose |
|-----------|---------|
| `EncryptCookies` | Encrypt/decrypt cookies |
| `AddQueuedCookiesToResponse` | Attach queued cookies |
| `StartSession` | Session handling |
| `ShareErrorsFromSession` | Flash validation errors |
| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |


text:
Script completed
Wall time 0.1 seconds
Output:

text:
specs/02-API-ROUTES.md:136:| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |
specs/02-API-ROUTES.md:1404:| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1694:#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload
specs/02-API-ROUTES.md:1696:Generate a presigned URL for uploading product media directly to object storage (S3).
specs/02-API-ROUTES.md:1718:  "upload_url": "https://s3.example.com/...presigned...",
specs/02-API-ROUTES.md:2764:### 3.11 Analytics and Exports
specs/02-API-ROUTES.md:2771:| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2772:| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2834:#### POST /api/admin/v1/stores/{storeId}/exports/orders
specs/02-API-ROUTES.md:2836:Queue an order export job. The export runs asynchronously and produces a downloadable CSV.
specs/02-API-ROUTES.md:2860:  "export_id": 1,
specs/02-API-ROUTES.md:2868:#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}
specs/02-API-ROUTES.md:2870:Check export status and get download URL.
specs/02-API-ROUTES.md:2881:    "download_url": "https://s3.example.com/...presigned...",
specs/02-API-ROUTES.md:2889:**Export status values:** `queued`, `processing`, `completed`, `failed`.
specs/02-API-ROUTES.md:2893:- `404` - Export not found
specs/02-API-ROUTES.md:2911:### GET /oauth/authorize
specs/02-API-ROUTES.md:2931:### POST /oauth/token
specs/02-API-ROUTES.md:2983:| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
specs/02-API-ROUTES.md:2984:| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
specs/02-API-ROUTES.md:2985:| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |
specs/02-API-ROUTES.md:3173:- **App API:** Versioned via URL path (`/api/apps/v1/`).
routes/api.php:49:    Route::post('products/{productId}/media/presign-upload', [PlatformController::class, 'presignMediaUpload'])->middleware('role.check:owner,admin,staff');
routes/web.php:102:Route::get('/oauth/authorize', fn (): \Illuminate\Http\JsonResponse => response()->json(['message' => 'OAuth is not enabled in this deployment.'], 501))->middleware('auth')->name('oauth.authorize');
routes/web.php:103:Route::post('/oauth/token', fn (): \Illuminate\Http\JsonResponse => response()->json(['message' => 'OAuth is not enabled in this deployment.'], 501))->name('oauth.token');
app/Http/Controllers/Api/PlatformController.php:82:    public function presignMediaUpload(PresignMediaUploadRequest $request, int $storeId, int $productId): JsonResponse
app/Http/Controllers/Api/PlatformController.php:107:                // Local development disks do not support presigned uploads.
database/migrations/2026_08_20_000000_create_shop_schema.php:528:        Schema::create('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:538:        Schema::create('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:540:            $table->foreignId('oauth_client_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php:578:            'webhook_deliveries', 'webhook_subscriptions', 'oauth_tokens', 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:59:        Schema::table('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:60:            if (! Schema::hasColumn('oauth_clients', 'app_id')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:64:            if (! Schema::hasColumn('oauth_clients', 'redirect_uris_json')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:69:        Schema::table('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:70:            if (! Schema::hasColumn('oauth_tokens', 'installation_id')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:74:            if (! Schema::hasColumn('oauth_tokens', 'access_token_hash')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:78:            if (! Schema::hasColumn('oauth_tokens', 'refresh_token_hash')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:108:            'app_id' => 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:109:            'redirect_uris_json' => 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:110:            'installation_id' => 'oauth_tokens',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:111:            'access_token_hash' => 'oauth_tokens',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:112:            'refresh_token_hash' => 'oauth_tokens',
{
  "data": {
    "store_id": 1,
    "index_status": "ready",
    "last_reindex_at": "2026-02-11T08:00:00Z",
    "last_reindex_duration_seconds": 45,
    "documents_count": 342,
    "pending_updates": 0
  }
}
```

---

### 3.11 Analytics and Exports

#### Admin Analytics Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/analytics/summary

Get analytics summary for a date range.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `from` | string (YYYY-MM-DD) | Yes | Start date. |
| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |
| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |

**Success response:** `200 OK`

```json
{
  "data": {
    "period": {
      "from": "2026-02-01",
      "to": "2026-02-11"
    },
    "summary": {
      "orders_count": 156,
      "revenue_amount": 1250000,
      "aov_amount": 8012,
      "visits_count": 4520,
      "add_to_cart_count": 890,
      "checkout_started_count": 420,
      "conversion_rate": 0.0345,
      "currency": "EUR"
    },
    "daily": [
      {
        "date": "2026-02-01",
        "orders_count": 12,
        "revenue_amount": 95000,
        "aov_amount": 7917,
        "visits_count": 380,
        "add_to_cart_count": 72,
        "checkout_started_count": 35
      }
    ],
    "top_products": [
      {
        "product_id": 10,
        "title": "Classic T-Shirt",
        "units_sold": 45,
        "revenue_amount": 112500
      }
    ]
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid date range

---

#### POST /api/admin/v1/stores/{storeId}/exports/orders

Queue an order export job. The export runs asynchronously and produces a downloadable CSV.

**Request body:**

```json
{
  "format": "csv",
  "filters": {
    "status": "paid",
    "created_after": "2026-01-01T00:00:00Z",
    "created_before": "2026-02-11T23:59:59Z"
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `format` | string | No | One of: `csv`. Default: `csv`. |
| `filters` | object | No | Same filter options as GET /orders. |

**Success response:** `202 Accepted`

```json
{
  "export_id": 1,
  "status": "queued",
  "created_at": "2026-02-11T10:00:00Z"
}
```

---

#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}

Check export status and get download URL.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 1,
    "status": "completed",
    "format": "csv",
    "row_count": 156,
    "download_url": "https://s3.example.com/...presigned...",
    "download_expires_at": "2026-02-11T11:00:00Z",
    "created_at": "2026-02-11T10:00:00Z",
    "completed_at": "2026-02-11T10:01:30Z"
  }
}
```

**Export status values:** `queued`, `processing`, `completed`, `failed`.

**Error responses:**
- `403` - Insufficient permissions
- `404` - Export not found

---

---

## 5. OAuth Endpoints (App Ecosystem)

> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.

These endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.

---

### GET /oauth/authorize

Display the authorization prompt to the merchant.

- **Middleware:** `web`, `auth`

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | string | Yes | The app's OAuth client ID |
| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |
| `response_type` | string | Yes | Must be `code` |
| `scope` | string | Yes | Space-separated list of requested scopes |
| `state` | string | Yes | CSRF token for the client |

**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.

---

### POST /oauth/token

Exchange an authorization code for access and refresh tokens.

- **Middleware:** None (public endpoint)

**Authorization code exchange request:**

```json
{
  "grant_type": "authorization_code",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "redirect_uri": "https://myapp.example.com/callback",
  "code": "auth_code_abc123"
}
```

**Success response:** `200 OK`

```json
{
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "eyJ...",
  "refresh_token": "def..."
}
```

**Error responses:**
- `400` - Invalid grant (code already used, expired, etc.)
- `401` - Invalid client credentials

**Refresh token request:**

```json
{
  "grant_type": "refresh_token",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "refresh_token": "def..."
}
```

---

### App API Endpoints

Third-party apps use OAuth tokens to access store data via scoped endpoints.

| Method | Path | Scope Required | Description |
|--------|------|----------------|-------------|
| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |

Token scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.

---

## 6. Middleware Stack

### 6.1 Route Group Middleware

#### Web (default)

Applied to all web routes via the `web` middleware group:

| Middleware | Purpose |
|-----------|---------|
| `EncryptCookies` | Encrypt/decrypt cookies |
| `AddQueuedCookiesToResponse` | Attach queued cookies |
| `StartSession` | Session handling |
| `ShareErrorsFromSession` | Flash validation errors |
| `VerifyCsrfToken` | CSRF protection on POST/PUT/PATCH/DELETE |