status: completed

input:
*** Begin Patch
*** Update File: app/Services/CheckoutService.php
@@
     public function createFromCart(Cart $cart, ?Customer $customer = null): Checkout
     {
-        $cart = Cart::withoutGlobalScopes()->findOrFail($cart->getKey());
+        return DB::transaction(function () use ($cart, $customer): Checkout {
+            $cart = Cart::withoutGlobalScopes()
+                ->whereKey($cart->getKey())
+                ->lockForUpdate()
+                ->firstOrFail();

-        if ($cart->status !== CartStatus::Active) {
-            throw InvalidCheckoutTransitionException::because('Checkout can only start from an active cart.');
-        }
+            if ($cart->status !== CartStatus::Active) {
+                throw InvalidCheckoutTransitionException::because('Checkout can only start from an active cart.');
+            }

-        if (! CartLine::withoutGlobalScopes()->where('cart_id', $cart->getKey())->exists()) {
-            throw InvalidCheckoutTransitionException::because('Checkout cannot start from an empty cart.');
-        }
+            if (! CartLine::withoutGlobalScopes()->where('cart_id', $cart->getKey())->exists()) {
+                throw InvalidCheckoutTransitionException::because('Checkout cannot start from an empty cart.');
+            }

-        return Checkout::withoutGlobalScopes()->create([
-            'store_id' => $cart->store_id,
-            'cart_id' => $cart->getKey(),
-            'customer_id' => $customer?->getKey() ?? $cart->customer_id,
-            'status' => CheckoutStatus::Started,
-        ]);
+            $existingCheckout = Checkout::withoutGlobalScopes()
+                ->where('cart_id', $cart->getKey())
+                ->whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value])
+                ->latest('id')
+                ->lockForUpdate()
+                ->first();
+
+            if ($existingCheckout instanceof Checkout) {
+                return $existingCheckout;
+            }
+
+            return Checkout::withoutGlobalScopes()->create([
+                'store_id' => $cart->store_id,
+                'cart_id' => $cart->getKey(),
+                'customer_id' => $customer?->getKey() ?? $cart->customer_id,
+                'status' => CheckoutStatus::Started,
+            ]);
+        });
     }
*** End Patch


stdout:
Success. Updated the following files:
M app/Services/CheckoutService.php

type: update

unified_diff:
@@ -29,18 +29,34 @@
     {
-        $cart = Cart::withoutGlobalScopes()->findOrFail($cart->getKey());
+        return DB::transaction(function () use ($cart, $customer): Checkout {
+            $cart = Cart::withoutGlobalScopes()
+                ->whereKey($cart->getKey())
+                ->lockForUpdate()
+                ->firstOrFail();

-        if ($cart->status !== CartStatus::Active) {
-            throw InvalidCheckoutTransitionException::because('Checkout can only start from an active cart.');
-        }
+            if ($cart->status !== CartStatus::Active) {
+                throw InvalidCheckoutTransitionException::because('Checkout can only start from an active cart.');
+            }
+
+            if (! CartLine::withoutGlobalScopes()->where('cart_id', $cart->getKey())->exists()) {
+                throw InvalidCheckoutTransitionException::because('Checkout cannot start from an empty cart.');
+            }
+
+            $existingCheckout = Checkout::withoutGlobalScopes()
+                ->where('cart_id', $cart->getKey())
+                ->whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value])
+                ->latest('id')
+                ->lockForUpdate()
+                ->first();

-        if (! CartLine::withoutGlobalScopes()->where('cart_id', $cart->getKey())->exists()) {
-            throw InvalidCheckoutTransitionException::because('Checkout cannot start from an empty cart.');
-        }
+            if ($existingCheckout instanceof Checkout) {
+                return $existingCheckout;
+            }

-        return Checkout::withoutGlobalScopes()->create([
-            'store_id' => $cart->store_id,
-            'cart_id' => $cart->getKey(),
-            'customer_id' => $customer?->getKey() ?? $cart->customer_id,
-            'status' => CheckoutStatus::Started,
-        ]);
+            return Checkout::withoutGlobalScopes()->create([
+                'store_id' => $cart->store_id,
+                'cart_id' => $cart->getKey(),
+                'customer_id' => $customer?->getKey() ?? $cart->customer_id,
+                'status' => CheckoutStatus::Started,
+            ]);
+        });
     }

status: completed


output: {"output":"Success. Updated the following files:\nM app/Services/CheckoutService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: app/Services/OrderService.php
@@
                 if ($checkout->totals_json === null) {
                     $this->pricing->calculate($checkout);
                     $checkout = $this->freshCheckout($checkout);
                 }

+                $store = $this->lockedStore($checkout);
+                $appliedDiscounts = $this->lockedAppliedDiscounts($checkout);
                 $method = $this->paymentMethod($checkout);
                 $paymentResult = $this->payments->charge($checkout, $method, $paymentMethodData);
@@
                     'store_id' => $checkout->store_id,
                     'checkout_id' => $checkout->getKey(),
                     'customer_id' => $checkout->customer_id,
-                    'order_number' => $this->nextOrderNumber($checkout->store),
+                    'order_number' => $this->nextOrderNumber($store),
@@
-                $this->createOrderLines($checkout, $order, $this->discountAllocationsByCartLine($checkout));
+                $this->createOrderLines($checkout, $order, $this->discountAllocationsByCartLine($checkout, $appliedDiscounts));
@@
-                $this->incrementDiscountUsage($checkout);
+                $this->incrementDiscountUsage($appliedDiscounts);
@@
     private function freshCheckout(Checkout $checkout): Checkout
     {
         return Checkout::withoutGlobalScopes()
             ->with(['cart', 'store'])
@@
             ->firstOrFail();
     }
+
+    private function lockedStore(Checkout $checkout): Store
+    {
+        return Store::withoutGlobalScopes()
+            ->whereKey($checkout->store_id)
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
@@
-    private function discountAllocationsByCartLine(Checkout $checkout): array
+    private function discountAllocationsByCartLine(Checkout $checkout, ?Collection $discounts = null): array
     {
         $lines = $this->cartLines($checkout)
@@
-        foreach ($this->appliedDiscounts($checkout) as $discount) {
+        foreach (($discounts ?? $this->appliedDiscounts($checkout)) as $discount) {
@@
     private function appliedDiscounts(Checkout $checkout): Collection
@@
             ->values();
     }
+
+    /**
+     * @return Collection<int, Discount>
+     */
+    private function lockedAppliedDiscounts(Checkout $checkout): Collection
+    {
+        $discountIds = $this->appliedDiscounts($checkout)
+            ->pluck('id')
+            ->filter()
+            ->values();
+
+        if ($discountIds->isEmpty()) {
+            return collect();
+        }
+
+        $discounts = Discount::withoutGlobalScopes()
+            ->whereIn('id', $discountIds->all())
+            ->orderBy('id')
+            ->lockForUpdate()
+            ->get();
+
+        $discounts->each(function (Discount $discount): void {
+            if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
+                throw InvalidCheckoutTransitionException::because('Discount usage limit has been reached.');
+            }
+        });
+
+        return $discounts;
+    }
@@
-    private function incrementDiscountUsage(Checkout $checkout): void
+    /**
+     * @param  Collection<int, Discount>  $discounts
+     */
+    private function incrementDiscountUsage(Collection $discounts): void
     {
-        $code = trim((string) $checkout->discount_code);
-
-        if ($code === '') {
-            return;
-        }
-
-        Discount::withoutGlobalScopes()
-            ->where('store_id', $checkout->store_id)
-            ->whereRaw('lower(code) = ?', [mb_strtolower($code)])
-            ->increment('usage_count');
+        $discounts->each(function (Discount $discount): void {
+            Discount::withoutGlobalScopes()
+                ->whereKey($discount->getKey())
+                ->increment('usage_count');
+        });
     }
*** End Patch


stdout:
Success. Updated the following files:
M app/Services/OrderService.php

unified_diff:
@@ -69,2 +69,4 @@

+                $store = $this->lockedStore($checkout);
+                $appliedDiscounts = $this->lockedAppliedDiscounts($checkout);
                 $method = $this->paymentMethod($checkout);
@@ -83,3 +85,3 @@
                     'customer_id' => $checkout->customer_id,
-                    'order_number' => $this->nextOrderNumber($checkout->store),
+                    'order_number' => $this->nextOrderNumber($store),
                     'payment_method' => $method,
@@ -100,3 +102,3 @@

-                $this->createOrderLines($checkout, $order, $this->discountAllocationsByCartLine($checkout));
+                $this->createOrderLines($checkout, $order, $this->discountAllocationsByCartLine($checkout, $appliedDiscounts));

@@ -125,3 +127,3 @@

-                $this->incrementDiscountUsage($checkout);
+                $this->incrementDiscountUsage($appliedDiscounts);

@@ -211,2 +213,10 @@

+    private function lockedStore(Checkout $checkout): Store
+    {
+        return Store::withoutGlobalScopes()
+            ->whereKey($checkout->store_id)
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
     private function freshOrder(Order $order): Order
@@ -291,3 +301,3 @@
      */
-    private function discountAllocationsByCartLine(Checkout $checkout): array
+    private function discountAllocationsByCartLine(Checkout $checkout, ?Collection $discounts = null): array
     {
@@ -305,3 +315,3 @@

-        foreach ($this->appliedDiscounts($checkout) as $discount) {
+        foreach (($discounts ?? $this->appliedDiscounts($checkout)) as $discount) {
             $result = $this->discounts->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all());
@@ -357,2 +367,31 @@

+    /**
+     * @return Collection<int, Discount>
+     */
+    private function lockedAppliedDiscounts(Checkout $checkout): Collection
+    {
+        $discountIds = $this->appliedDiscounts($checkout)
+            ->pluck('id')
+            ->filter()
+            ->values();
+
+        if ($discountIds->isEmpty()) {
+            return collect();
+        }
+
+        $discounts = Discount::withoutGlobalScopes()
+            ->whereIn('id', $discountIds->all())
+            ->orderBy('id')
+            ->lockForUpdate()
+            ->get();
+
+        $discounts->each(function (Discount $discount): void {
+            if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
+                throw InvalidCheckoutTransitionException::because('Discount usage limit has been reached.');
+            }
+        });
+
+        return $discounts;
+    }
+
     private function commitReservedInventory(Checkout $checkout): void
@@ -403,14 +442,12 @@

-    private function incrementDiscountUsage(Checkout $checkout): void
+    /**
+     * @param  Collection<int, Discount>  $discounts
+     */
+    private function incrementDiscountUsage(Collection $discounts): void
     {
-        $code = trim((string) $checkout->discount_code);
-
-        if ($code === '') {
-            return;
-        }
-
-        Discount::withoutGlobalScopes()
-            ->where('store_id', $checkout->store_id)
-            ->whereRaw('lower(code) = ?', [mb_strtolower($code)])
-            ->increment('usage_count');
+        $discounts->each(function (Discount $discount): void {
+            Discount::withoutGlobalScopes()
+                ->whereKey($discount->getKey())
+                ->increment('usage_count');
+        });
     }


output: {"output":"Success. Updated the following files:\nM app/Services/OrderService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: app/Services/OrderService.php
@@
-        $discounts = Discount::withoutGlobalScopes()
+        $lockedDiscounts = Discount::withoutGlobalScopes()
             ->whereIn('id', $discountIds->all())
             ->orderBy('id')
             ->lockForUpdate()
-            ->get();
+            ->get()
+            ->keyBy(fn (Discount $discount): int => $discount->getKey());

-        $discounts->each(function (Discount $discount): void {
+        $discounts = $discountIds
+            ->map(fn (int $discountId): ?Discount => $lockedDiscounts->get($discountId))
+            ->filter()
+            ->values();
+
+        $discounts->each(function (Discount $discount): void {
*** End Patch


unified_diff:
@@ -381,3 +381,3 @@

-        $discounts = Discount::withoutGlobalScopes()
+        $lockedDiscounts = Discount::withoutGlobalScopes()
             ->whereIn('id', $discountIds->all())
@@ -385,3 +385,9 @@
             ->lockForUpdate()
-            ->get();
+            ->get()
+            ->keyBy(fn (Discount $discount): int => $discount->getKey());
+
+        $discounts = $discountIds
+            ->map(fn (int $discountId): ?Discount => $lockedDiscounts->get($discountId))
+            ->filter()
+            ->values();


output: {"output":"Success. Updated the following files:\nM app/Services/OrderService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


command:
/bin/zsh
-lc
sed -n '1,80p' routes/web.php

web.php

type: read

cmd: sed -n '1,80p' routes/web.php

aggregated_output:
<?php

use App\Http\Controllers\Api\OAuthController;
use App\Http\Controllers\Storefront\Account\Auth\CustomerPasswordResetController;
use App\Http\Middleware\EnsureUserEmailIsVerified;
use App\Livewire\Admin\Analytics\Index as AdminAnalyticsIndex;
use App\Livewire\Admin\Apps\Index as AdminAppsIndex;
use App\Livewire\Admin\Apps\Show as AdminAppShow;
use App\Livewire\Admin\Auth\Login as AdminLogin;
use App\Livewire\Admin\Collections\Form as AdminCollectionForm;
use App\Livewire\Admin\Collections\Index as AdminCollectionsIndex;
use App\Livewire\Admin\Customers\Index as AdminCustomersIndex;
use App\Livewire\Admin\Customers\Show as AdminCustomerShow;
use App\Livewire\Admin\Dashboard as AdminDashboard;
use App\Livewire\Admin\Developers\Index as AdminDevelopersIndex;
use App\Livewire\Admin\Discounts\Form as AdminDiscountForm;
use App\Livewire\Admin\Discounts\Index as AdminDiscountsIndex;
use App\Livewire\Admin\Inventory\Index as AdminInventoryIndex;
use App\Livewire\Admin\Navigation\Index as AdminNavigationIndex;
use App\Livewire\Admin\Orders\Index as AdminOrdersIndex;
use App\Livewire\Admin\Orders\Show as AdminOrderShow;
use App\Livewire\Admin\Pages\Form as AdminPageForm;
use App\Livewire\Admin\Pages\Index as AdminPagesIndex;
use App\Livewire\Admin\Products\Form as AdminProductForm;
use App\Livewire\Admin\Products\Index as AdminProductsIndex;
use App\Livewire\Admin\Search\Settings as AdminSearchSettings;
use App\Livewire\Admin\Settings\Checkout as AdminSettingsCheckout;
use App\Livewire\Admin\Settings\Index as AdminSettingsIndex;
use App\Livewire\Admin\Settings\Notifications as AdminSettingsNotifications;
use App\Livewire\Admin\Settings\Shipping as AdminSettingsShipping;
use App\Livewire\Admin\Settings\Taxes as AdminSettingsTaxes;
use App\Livewire\Admin\Themes\Editor as AdminThemeEditor;
use App\Livewire\Admin\Themes\Index as AdminThemesIndex;
use App\Livewire\Storefront\Account\Addresses\Index as CustomerAddressesIndex;
use App\Livewire\Storefront\Account\Auth\ForgotPassword as CustomerForgotPassword;
use App\Livewire\Storefront\Account\Auth\Login as CustomerLogin;
use App\Livewire\Storefront\Account\Auth\Register as CustomerRegister;
use App\Livewire\Storefront\Account\Auth\ResetPassword as CustomerResetPassword;
use App\Livewire\Storefront\Account\Orders\Index as CustomerOrdersIndex;
use App\Livewire\Storefront\Account\Orders\Show as CustomerOrderShow;
use App\Livewire\Storefront\Cart\Show as StorefrontCartShow;
use App\Livewire\Storefront\Checkout\Confirmation as StorefrontCheckoutConfirmation;
use App\Livewire\Storefront\Checkout\Show as StorefrontCheckoutShow;
use App\Livewire\Storefront\Collections\Index as StorefrontCollectionsIndex;
use App\Livewire\Storefront\Collections\Show as StorefrontCollectionShow;
use App\Livewire\Storefront\Home as StorefrontHome;
use App\Livewire\Storefront\Pages\Show as StorefrontPageShow;
use App\Livewire\Storefront\Products\Show as StorefrontProductShow;
use App\Livewire\Storefront\Search\Index as StorefrontSearchIndex;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Http\Controllers\NewPasswordController as FortifyNewPasswordController;
use Laravel\Fortify\Http\Controllers\PasswordResetLinkController as FortifyPasswordResetLinkController;

Route::middleware(['storefront'])->group(function (): void {
    Route::livewire('/', StorefrontHome::class)->name('home');
    Route::livewire('collections', StorefrontCollectionsIndex::class)->name('collections.index');
    Route::livewire('collections/{handle}', StorefrontCollectionShow::class)->name('collections.show');
    Route::livewire('products/{handle}', StorefrontProductShow::class)->name('products.show');
    Route::livewire('cart', StorefrontCartShow::class)->name('cart.show');
    Route::livewire('checkout', StorefrontCheckoutShow::class)->name('checkout.show');
    Route::livewire('checkout/confirmation/{order}', StorefrontCheckoutConfirmation::class)->name('checkout.confirmation');
    Route::livewire('search', StorefrontSearchIndex::class)->name('search.index');
    Route::livewire('pages/{handle}', StorefrontPageShow::class)->name('pages.show');

    Route::livewire('forgot-password', CustomerForgotPassword::class)
        ->middleware('guest:customer')
        ->name('customer.password.request');

    Route::post('forgot-password', [CustomerPasswordResetController::class, 'send'])
        ->middleware('guest:customer')
        ->name('customer.password.email');

    Route::livewire('reset-password/{token}', CustomerResetPassword::class)
        ->middleware('guest:customer')
        ->name('customer.password.reset');

    Route::post('reset-password', [CustomerPasswordResetController::class, 'update'])
        ->middleware('guest:customer')
        ->name('customer.password.update');


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

use App\Http\Controllers\Api\OAuthController;
use App\Http\Controllers\Storefront\Account\Auth\CustomerPasswordResetController;
use App\Http\Middleware\EnsureUserEmailIsVerified;
use App\Livewire\Admin\Analytics\Index as AdminAnalyticsIndex;
use App\Livewire\Admin\Apps\Index as AdminAppsIndex;
use App\Livewire\Admin\Apps\Show as AdminAppShow;
use App\Livewire\Admin\Auth\Login as AdminLogin;
use App\Livewire\Admin\Collections\Form as AdminCollectionForm;
use App\Livewire\Admin\Collections\Index as AdminCollectionsIndex;
use App\Livewire\Admin\Customers\Index as AdminCustomersIndex;
use App\Livewire\Admin\Customers\Show as AdminCustomerShow;
use App\Livewire\Admin\Dashboard as AdminDashboard;
use App\Livewire\Admin\Developers\Index as AdminDevelopersIndex;
use App\Livewire\Admin\Discounts\Form as AdminDiscountForm;
use App\Livewire\Admin\Discounts\Index as AdminDiscountsIndex;
use App\Livewire\Admin\Inventory\Index as AdminInventoryIndex;
use App\Livewire\Admin\Navigation\Index as AdminNavigationIndex;
use App\Livewire\Admin\Orders\Index as AdminOrdersIndex;
use App\Livewire\Admin\Orders\Show as AdminOrderShow;
use App\Livewire\Admin\Pages\Form as AdminPageForm;
use App\Livewire\Admin\Pages\Index as AdminPagesIndex;
use App\Livewire\Admin\Products\Form as AdminProductForm;
use App\Livewire\Admin\Products\Index as AdminProductsIndex;
use App\Livewire\Admin\Search\Settings as AdminSearchSettings;
use App\Livewire\Admin\Settings\Checkout as AdminSettingsCheckout;
use App\Livewire\Admin\Settings\Index as AdminSettingsIndex;
use App\Livewire\Admin\Settings\Notifications as AdminSettingsNotifications;
use App\Livewire\Admin\Settings\Shipping as AdminSettingsShipping;
use App\Livewire\Admin\Settings\Taxes as AdminSettingsTaxes;
use App\Livewire\Admin\Themes\Editor as AdminThemeEditor;
use App\Livewire\Admin\Themes\Index as AdminThemesIndex;
use App\Livewire\Storefront\Account\Addresses\Index as CustomerAddressesIndex;
use App\Livewire\Storefront\Account\Auth\ForgotPassword as CustomerForgotPassword;
use App\Livewire\Storefront\Account\Auth\Login as CustomerLogin;
use App\Livewire\Storefront\Account\Auth\Register as CustomerRegister;
use App\Livewire\Storefront\Account\Auth\ResetPassword as CustomerResetPassword;
use App\Livewire\Storefront\Account\Orders\Index as CustomerOrdersIndex;
use App\Livewire\Storefront\Account\Orders\Show as CustomerOrderShow;
use App\Livewire\Storefront\Cart\Show as StorefrontCartShow;
use App\Livewire\Storefront\Checkout\Confirmation as StorefrontCheckoutConfirmation;
use App\Livewire\Storefront\Checkout\Show as StorefrontCheckoutShow;
use App\Livewire\Storefront\Collections\Index as StorefrontCollectionsIndex;
use App\Livewire\Storefront\Collections\Show as StorefrontCollectionShow;
use App\Livewire\Storefront\Home as StorefrontHome;
use App\Livewire\Storefront\Pages\Show as StorefrontPageShow;
use App\Livewire\Storefront\Products\Show as StorefrontProductShow;
use App\Livewire\Storefront\Search\Index as StorefrontSearchIndex;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Http\Controllers\NewPasswordController as FortifyNewPasswordController;
use Laravel\Fortify\Http\Controllers\PasswordResetLinkController as FortifyPasswordResetLinkController;

Route::middleware(['storefront'])->group(function (): void {
    Route::livewire('/', StorefrontHome::class)->name('home');
    Route::livewire('collections', StorefrontCollectionsIndex::class)->name('collections.index');
    Route::livewire('collections/{handle}', StorefrontCollectionShow::class)->name('collections.show');
    Route::livewire('products/{handle}', StorefrontProductShow::class)->name('products.show');
    Route::livewire('cart', StorefrontCartShow::class)->name('cart.show');
    Route::livewire('checkout', StorefrontCheckoutShow::class)->name('checkout.show');
    Route::livewire('checkout/confirmation/{order}', StorefrontCheckoutConfirmation::class)->name('checkout.confirmation');
    Route::livewire('search', StorefrontSearchIndex::class)->name('search.index');
    Route::livewire('pages/{handle}', StorefrontPageShow::class)->name('pages.show');

    Route::livewire('forgot-password', CustomerForgotPassword::class)
        ->middleware('guest:customer')
        ->name('customer.password.request');

    Route::post('forgot-password', [CustomerPasswordResetController::class, 'send'])
        ->middleware('guest:customer')
        ->name('customer.password.email');

    Route::livewire('reset-password/{token}', CustomerResetPassword::class)
        ->middleware('guest:customer')
        ->name('customer.password.reset');

    Route::post('reset-password', [CustomerPasswordResetController::class, 'update'])
        ->middleware('guest:customer')
        ->name('customer.password.update');


status: completed

input:
*** Begin Patch
*** Update File: routes/web.php
@@
 use App\Livewire\Storefront\Search\Index as StorefrontSearchIndex;
+use App\Models\Order;
 use Illuminate\Support\Facades\Auth;
@@
     Route::livewire('collections/{handle}', StorefrontCollectionShow::class)->name('collections.show');
     Route::livewire('products/{handle}', StorefrontProductShow::class)->name('products.show');
     Route::livewire('cart', StorefrontCartShow::class)->name('cart.show');
-    Route::livewire('checkout', StorefrontCheckoutShow::class)->name('checkout.show');
-    Route::livewire('checkout/confirmation/{order}', StorefrontCheckoutConfirmation::class)->name('checkout.confirmation');
+    Route::livewire('checkout/{checkout}/confirmation', StorefrontCheckoutConfirmation::class)
+        ->whereNumber('checkout')
+        ->name('checkout.confirmation');
+    Route::get('checkout/confirmation/{order}', function (Order $order) {
+        abort_if($order->checkout_id === null, 404);
+
+        return redirect()->route('checkout.confirmation', ['checkout' => $order->checkout_id]);
+    })->whereNumber('order')->name('checkout.confirmation.legacy');
+    Route::livewire('checkout/{checkout?}', StorefrontCheckoutShow::class)
+        ->whereNumber('checkout')
+        ->name('checkout.show');
     Route::livewire('search', StorefrontSearchIndex::class)->name('search.index');
*** End Patch


stdout:
Success. Updated the following files:
M routes/web.php

unified_diff:
@@ -49,2 +49,3 @@
 use App\Livewire\Storefront\Search\Index as StorefrontSearchIndex;
+use App\Models\Order;
 use Illuminate\Support\Facades\Auth;
@@ -60,4 +61,13 @@
     Route::livewire('cart', StorefrontCartShow::class)->name('cart.show');
-    Route::livewire('checkout', StorefrontCheckoutShow::class)->name('checkout.show');
-    Route::livewire('checkout/confirmation/{order}', StorefrontCheckoutConfirmation::class)->name('checkout.confirmation');
+    Route::livewire('checkout/{checkout}/confirmation', StorefrontCheckoutConfirmation::class)
+        ->whereNumber('checkout')
+        ->name('checkout.confirmation');
+    Route::get('checkout/confirmation/{order}', function (Order $order) {
+        abort_if($order->checkout_id === null, 404);
+
+        return redirect()->route('checkout.confirmation', ['checkout' => $order->checkout_id]);
+    })->whereNumber('order')->name('checkout.confirmation.legacy');
+    Route::livewire('checkout/{checkout?}', StorefrontCheckoutShow::class)
+        ->whereNumber('checkout')
+        ->name('checkout.show');
     Route::livewire('search', StorefrontSearchIndex::class)->name('search.index');


output: {"output":"Success. Updated the following files:\nM routes/web.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: app/Livewire/Storefront/Checkout/Show.php
@@
     #[Locked]
     public int $storeId;

+    #[Locked]
+    public ?int $checkoutId = null;
+
@@
-    public function mount(): void
+    public function mount(?Checkout $checkout = null): void
     {
         $this->storeId = $this->store()->getKey();
         $this->email = $this->customer()?->email ?? '';

+        if ($checkout instanceof Checkout) {
+            $this->mountCheckout($checkout);
+        }
+
         $this->fillFromCheckout($this->checkout());
     }
@@
-            $this->redirectRoute('checkout.confirmation', ['order' => $order], navigate: true);
+            $this->redirectRoute('checkout.confirmation', ['checkout' => $checkout->getKey()], navigate: true);
@@
     public function cart(): ?Cart
     {
+        if ($this->checkoutId !== null) {
+            $checkout = Checkout::withoutGlobalScopes()
+                ->with('cart')
+                ->where('store_id', $this->storeId)
+                ->whereKey($this->checkoutId)
+                ->first();
+
+            if ($checkout instanceof Checkout) {
+                return $checkout->cart?->load([
+                    'lines.variant.product',
+                    'lines.variant.optionValues.option',
+                ]);
+            }
+        }
+
         $cart = app(CartService::class)->currentForSession($this->store(), $this->customer());

@@
     public function checkout(): ?Checkout
     {
+        if ($this->checkoutId !== null) {
+            $checkout = Checkout::withoutGlobalScopes()
+                ->where('store_id', $this->storeId)
+                ->whereKey($this->checkoutId)
+                ->first();
+
+            if (! $checkout instanceof Checkout) {
+                return null;
+            }
+
+            $this->authorizeCheckoutAccess($checkout);
+
+            return $this->syncSessionDiscount($checkout)
+                ->load(['cart.lines.variant.product', 'cart.lines.variant.optionValues.option']);
+        }
+
         $cart = $this->cart();

         if (! $cart instanceof Cart || $cart->lines->isEmpty()) {
@@
         if (! $checkout instanceof Checkout) {
             $checkout = app(CheckoutService::class)->createFromCart($cart, $this->customer());
         }

+        $this->checkoutId = $checkout->getKey();
         $checkout = $this->syncSessionDiscount($checkout);
@@
     private function customer(): ?Customer
@@
         return $customer instanceof Customer ? $customer : null;
     }
+
+    private function mountCheckout(Checkout $checkout): void
+    {
+        $checkout = Checkout::withoutGlobalScopes()
+            ->where('store_id', $this->storeId)
+            ->whereKey($checkout->getKey())
+            ->first();
+
+        abort_unless($checkout instanceof Checkout, 404);
+
+        $this->authorizeCheckoutAccess($checkout);
+
+        $this->checkoutId = $checkout->getKey();
+    }
+
+    private function authorizeCheckoutAccess(Checkout $checkout): void
+    {
+        $customer = $this->customer();
+        $sessionCartId = session('cart_id');
+        $isCustomerCheckout = $customer instanceof Customer && $checkout->customer_id === $customer->getKey();
+        $isSessionCheckout = $sessionCartId !== null && (int) $sessionCartId === (int) $checkout->cart_id;
+
+        abort_unless($isCustomerCheckout || $isSessionCheckout, 404);
+    }
*** End Patch


stdout:
Success. Updated the following files:
M app/Livewire/Storefront/Checkout/Show.php

unified_diff:
@@ -31,2 +31,5 @@

+    #[Locked]
+    public ?int $checkoutId = null;
+
     public string $step = 'address';
@@ -79,3 +82,3 @@

-    public function mount(): void
+    public function mount(?Checkout $checkout = null): void
     {
@@ -84,2 +87,6 @@

+        if ($checkout instanceof Checkout) {
+            $this->mountCheckout($checkout);
+        }
+
         $this->fillFromCheckout($this->checkout());
@@ -251,3 +258,3 @@

-            $this->redirectRoute('checkout.confirmation', ['order' => $order], navigate: true);
+            $this->redirectRoute('checkout.confirmation', ['checkout' => $checkout->getKey()], navigate: true);
         } catch (InvalidCheckoutTransitionException|PaymentFailedException $exception) {
@@ -279,2 +286,17 @@
     {
+        if ($this->checkoutId !== null) {
+            $checkout = Checkout::withoutGlobalScopes()
+                ->with('cart')
+                ->where('store_id', $this->storeId)
+                ->whereKey($this->checkoutId)
+                ->first();
+
+            if ($checkout instanceof Checkout) {
+                return $checkout->cart?->load([
+                    'lines.variant.product',
+                    'lines.variant.optionValues.option',
+                ]);
+            }
+        }
+
         $cart = app(CartService::class)->currentForSession($this->store(), $this->customer());
@@ -289,2 +311,18 @@
     {
+        if ($this->checkoutId !== null) {
+            $checkout = Checkout::withoutGlobalScopes()
+                ->where('store_id', $this->storeId)
+                ->whereKey($this->checkoutId)
+                ->first();
+
+            if (! $checkout instanceof Checkout) {
+                return null;
+            }
+
+            $this->authorizeCheckoutAccess($checkout);
+
+            return $this->syncSessionDiscount($checkout)
+                ->load(['cart.lines.variant.product', 'cart.lines.variant.optionValues.option']);
+        }
+
         $cart = $this->cart();
@@ -305,2 +343,3 @@

+        $this->checkoutId = $checkout->getKey();
         $checkout = $this->syncSessionDiscount($checkout);
@@ -405,2 +444,26 @@

+    private function mountCheckout(Checkout $checkout): void
+    {
+        $checkout = Checkout::withoutGlobalScopes()
+            ->where('store_id', $this->storeId)
+            ->whereKey($checkout->getKey())
+            ->first();
+
+        abort_unless($checkout instanceof Checkout, 404);
+
+        $this->authorizeCheckoutAccess($checkout);
+
+        $this->checkoutId = $checkout->getKey();
+    }
+
+    private function authorizeCheckoutAccess(Checkout $checkout): void
+    {
+        $customer = $this->customer();
+        $sessionCartId = session('cart_id');
+        $isCustomerCheckout = $customer instanceof Customer && $checkout->customer_id === $customer->getKey();
+        $isSessionCheckout = $sessionCartId !== null && (int) $sessionCartId === (int) $checkout->cart_id;
+
+        abort_unless($isCustomerCheckout || $isSessionCheckout, 404);
+    }
+
     private function fillFromCheckout(?Checkout $checkout): void


output: {"output":"Success. Updated the following files:\nM app/Livewire/Storefront/Checkout/Show.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: app/Livewire/Storefront/Cart/Show.php
@@
 use App\Services\CartService;
+use App\Services\CheckoutService;
@@
     public function checkout(): void
     {
         if ($this->lineCount() === 0) {
             return;
         }

-        $this->redirectRoute('checkout.show', navigate: true);
+        $cart = $this->cart();
+
+        if (! $cart instanceof Cart) {
+            return;
+        }
+
+        $checkout = app(CheckoutService::class)->createFromCart($cart, $this->customer());
+
+        $this->redirectRoute('checkout.show', ['checkout' => $checkout->getKey()], navigate: true);
     }
*** Update File: app/Livewire/Storefront/CartDrawer.php
@@
 use App\Models\Store;
 use App\Services\CartService;
+use App\Services\CheckoutService;
@@
     public function checkout(): void
     {
         if ($this->lineCount() === 0) {
             return;
         }

-        $this->redirectRoute('checkout.show', navigate: true);
+        $cart = $this->cart();
+
+        if (! $cart instanceof Cart) {
+            return;
+        }
+
+        $checkout = app(CheckoutService::class)->createFromCart($cart, $this->customer());
+
+        $this->redirectRoute('checkout.show', ['checkout' => $checkout->getKey()], navigate: true);
     }
*** End Patch


stdout:
Success. Updated the following files:
M app/Livewire/Storefront/Cart/Show.php
M app/Livewire/Storefront/CartDrawer.php

unified_diff:
@@ -9,2 +9,3 @@
 use App\Services\CartService;
+use App\Services\CheckoutService;
 use Illuminate\Support\Collection;
@@ -68,3 +69,11 @@

-        $this->redirectRoute('checkout.show', navigate: true);
+        $cart = $this->cart();
+
+        if (! $cart instanceof Cart) {
+            return;
+        }
+
+        $checkout = app(CheckoutService::class)->createFromCart($cart, $this->customer());
+
+        $this->redirectRoute('checkout.show', ['checkout' => $checkout->getKey()], navigate: true);
     }

unified_diff:
@@ -13,2 +13,3 @@
 use App\Services\CartService;
+use App\Services\CheckoutService;
 use App\Services\DiscountService;
@@ -147,3 +148,11 @@

-        $this->redirectRoute('checkout.show', navigate: true);
+        $cart = $this->cart();
+
+        if (! $cart instanceof Cart) {
+            return;
+        }
+
+        $checkout = app(CheckoutService::class)->createFromCart($cart, $this->customer());
+
+        $this->redirectRoute('checkout.show', ['checkout' => $checkout->getKey()], navigate: true);
     }


output: {"output":"Success. Updated the following files:\nM app/Livewire/Storefront/Cart/Show.php\nM app/Livewire/Storefront/CartDrawer.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: app/Livewire/Storefront/Checkout/Confirmation.php
@@
 use App\Models\Customer;
+use App\Models\Checkout;
 use App\Models\Order;
@@
-    public int $orderId;
+    public int $orderId;

-    public function mount(Order $order): void
+    public function mount(Checkout $checkout): void
     {
         $store = app('current_store');

         abort_unless($store instanceof Store, 404);

-        $order = Order::withoutGlobalScopes()
-            ->where('store_id', $store->getKey())
-            ->whereKey($order->getKey())
+        $checkout = Checkout::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->whereKey($checkout->getKey())
             ->first();

+        abort_unless($checkout instanceof Checkout, 404);
+
+        $order = Order::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->where('checkout_id', $checkout->getKey())
+            ->first();
+
         abort_unless($order instanceof Order, 404);
*** End Patch


stdout:
Success. Updated the following files:
M app/Livewire/Storefront/Checkout/Confirmation.php

unified_diff:
@@ -5,2 +5,3 @@
 use App\Models\Customer;
+use App\Models\Checkout;
 use App\Models\Order;
@@ -19,3 +20,3 @@

-    public function mount(Order $order): void
+    public function mount(Checkout $checkout): void
     {
@@ -25,5 +26,12 @@

+        $checkout = Checkout::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->whereKey($checkout->getKey())
+            ->first();
+
+        abort_unless($checkout instanceof Checkout, 404);
+
         $order = Order::withoutGlobalScopes()
             ->where('store_id', $store->getKey())
-            ->whereKey($order->getKey())
+            ->where('checkout_id', $checkout->getKey())
             ->first();


output: {"output":"Success. Updated the following files:\nM app/Livewire/Storefront/Checkout/Confirmation.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: resources/views/livewire/storefront/checkout/confirmation.blade.php
@@
     <x-storefront.breadcrumbs :items="[
         ['label' => 'Cart', 'url' => route('cart.show')],
-        ['label' => 'Checkout', 'url' => route('checkout.show')],
+        ['label' => 'Checkout', 'url' => $order->checkout_id ? route('checkout.show', ['checkout' => $order->checkout_id]) : route('cart.show')],
         ['label' => 'Confirmation'],
     ]" />
*** End Patch


stdout:
Success. Updated the following files:
M resources/views/livewire/storefront/checkout/confirmation.blade.php

unified_diff:
@@ -11,3 +11,3 @@
         ['label' => 'Cart', 'url' => route('cart.show')],
-        ['label' => 'Checkout', 'url' => route('checkout.show')],
+        ['label' => 'Checkout', 'url' => $order->checkout_id ? route('checkout.show', ['checkout' => $order->checkout_id]) : route('cart.show')],
         ['label' => 'Confirmation'],


output: {"output":"Success. Updated the following files:\nM resources/views/livewire/storefront/checkout/confirmation.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


cmd: rg -n "route\('checkout\.show'|redirectRoute\('checkout\.show'|route\('checkout\.confirmation'|redirectRoute\('checkout\.confirmation'" app resources tests


command: rg -n "route\('checkout\.show'|redirectRoute\('checkout\.show'|route\('checkout\.confirmation'|redirectRoute\('checkout\.confirmation'" app resources tests

type: search

cmd: rg -n "route\\('checkout\\.show'|redirectRoute\\('checkout\\.show'|route\\('checkout\\.confirmation'|redirectRoute\\('checkout\\.confirmation'" app resources tests

query: route\('checkout\.show'|redirectRoute\('checkout\.show'|route\('checkout\.confirmation'|redirectRoute\('checkout\.confirmation'

path: app

aggregated_output:
tests/Feature/Storefront/OrderViewsTest.php:88:    $component->assertRedirect(route('checkout.confirmation', $order));
resources/views/livewire/storefront/checkout/confirmation.blade.php:12:        ['label' => 'Checkout', 'url' => $order->checkout_id ? route('checkout.show', ['checkout' => $order->checkout_id]) : route('cart.show')],
app/Livewire/Storefront/CartDrawer.php:78:        $this->redirectRoute('checkout.show', ['checkout' => $checkout->getKey()], navigate: true);
app/Livewire/Storefront/Checkout/Show.php:259:            $this->redirectRoute('checkout.confirmation', ['checkout' => $checkout->getKey()], navigate: true);
app/Livewire/Storefront/Cart/Show.php:157:        $this->redirectRoute('checkout.show', ['checkout' => $checkout->getKey()], navigate: true);


output:
Chunk ID: 240f4e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 195
Output:
tests/Feature/Storefront/OrderViewsTest.php:88:    $component->assertRedirect(route('checkout.confirmation', $order));
resources/views/livewire/storefront/checkout/confirmation.blade.php:12:        ['label' => 'Checkout', 'url' => $order->checkout_id ? route('checkout.show', ['checkout' => $order->checkout_id]) : route('cart.show')],
app/Livewire/Storefront/CartDrawer.php:78:        $this->redirectRoute('checkout.show', ['checkout' => $checkout->getKey()], navigate: true);
app/Livewire/Storefront/Checkout/Show.php:259:            $this->redirectRoute('checkout.confirmation', ['checkout' => $checkout->getKey()], navigate: true);
app/Livewire/Storefront/Cart/Show.php:157:        $this->redirectRoute('checkout.show', ['checkout' => $checkout->getKey()], navigate: true);


command:
/bin/zsh
-lc
sed -n '1,240p' tests/Pest.php

Pest.php

cmd: sed -n '1,240p' tests/Pest.php

aggregated_output:
<?php

/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/

pest()->extend(Tests\TestCase::class)
    ->in('Feature', 'Browser');

/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/

expect()->extend('toBeOne', function () {
    return $this->toBe(1);
});

/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/

function something()
{
    // ..
}


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

/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/

pest()->extend(Tests\TestCase::class)
    ->in('Feature', 'Browser');

/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/

expect()->extend('toBeOne', function () {
    return $this->toBe(1);
});

/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/

function something()
{
    // ..
}


command:
/bin/zsh
-lc
rg --files tests/Feature/Api | sort

cmd: rg --files tests/Feature/Api

path: Api


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


message: The API tests now need to match the `auth:sanctum` contract. I’m adding a shared Pest helper for bearer tokens, then I’ll update the admin API test calls to use explicit token abilities instead of `actingAs()` sessions.

phase: commentary


type: message

role: assistant

text: The API tests now need to match the `auth:sanctum` contract. I’m adding a shared Pest helper for bearer tokens, then I’ll update the admin API test calls to use explicit token abilities instead of `actingAs()` sessions.


status: completed

input:
*** Begin Patch
*** Update File: tests/Pest.php
@@
-function something()
+/**
+ * @param  list<string>  $abilities
+ */
+function adminApiBearerToken(\App\Models\Store $store, array $abilities, ?\App\Models\User $user = null): string
 {
-    // ..
+    return app(\App\Services\WebhookService::class)
+        ->createApiToken($store, 'Test API token', $abilities, $user)['plain_text'];
 }
*** End Patch


stdout:
Success. Updated the following files:
M tests/Pest.php

unified_diff:
@@ -42,5 +42,9 @@

-function something()
+/**
+ * @param  list<string>  $abilities
+ */
+function adminApiBearerToken(\App\Models\Store $store, array $abilities, ?\App\Models\User $user = null): string
 {
-    // ..
+    return app(\App\Services\WebhookService::class)
+        ->createApiToken($store, 'Test API token', $abilities, $user)['plain_text'];
 }


output: {"output":"Success. Updated the following files:\nM tests/Pest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


cmd: perl -0pi -e 's/\\App\\Models\\OauthToken/\\App\\Models\\PersonalAccessToken/g' tests/Feature/Api/*.php


command: perl -0pi -e 's/\\App\\Models\\OauthToken/\\App\\Models\\PersonalAccessToken/g' tests/Feature/Api/*.php

type: unknown


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


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Api/AdminCatalogApiTest.php
@@
-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-products'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query={$variant->sku}&sort=title_asc")
@@
-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-products'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
@@
-    $createResponse = $this->actingAs($user)
+    $writeToken = adminApiBearerToken($store, ['write-products'], $user);
+
+    $createResponse = $this->withToken($writeToken)
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
@@
-    $this->actingAs($user)
+    $this->withToken($writeToken)
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}", [
@@
-    $this->actingAs($user)
+    $this->withToken($writeToken)
         ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
@@
-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-products'], adminCatalogApiUser()))
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}/media/presign-upload", [
@@
-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-customers'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers?query=customer-api")
@@
-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-customers'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$customer->getKey()}")
@@
-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-products'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$otherProduct->getKey()}")
@@
-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-customers'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$otherCustomer->getKey()}")
*** End Patch


stdout:
Success. Updated the following files:
M tests/Feature/Api/AdminCatalogApiTest.php

unified_diff:
@@ -67,3 +67,3 @@

-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-products'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query={$variant->sku}&sort=title_asc")
@@ -75,3 +75,3 @@

-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-products'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
@@ -92,3 +92,5 @@

-    $createResponse = $this->actingAs($user)
+    $writeToken = adminApiBearerToken($store, ['write-products'], $user);
+
+    $createResponse = $this->withToken($writeToken)
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
@@ -161,3 +163,3 @@

-    $this->actingAs($user)
+    $this->withToken($writeToken)
         ->putJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}", [
@@ -189,3 +191,3 @@

-    $this->actingAs($user)
+    $this->withToken($writeToken)
         ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
@@ -204,3 +206,3 @@

-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['write-products'], adminCatalogApiUser()))
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}/media/presign-upload", [
@@ -245,3 +247,3 @@

-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-customers'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers?query=customer-api")
@@ -253,3 +255,3 @@

-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-customers'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$customer->getKey()}")
@@ -327,3 +329,3 @@

-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-products'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$otherProduct->getKey()}")
@@ -331,3 +333,3 @@

-    $this->actingAs(adminCatalogApiUser())
+    $this->withToken(adminApiBearerToken($store, ['read-customers'], adminCatalogApiUser()))
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$otherCustomer->getKey()}")


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


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Api/AdminPlatformApiTest.php
@@
-test('platform api creates organizations and stores for owner sessions', function (): void {
+test('platform api creates organizations and stores for owner bearer tokens', function (): void {
+    $store = adminPlatformApiStore();
     $user = adminPlatformApiUser();
+    $token = adminApiBearerToken($store, ['manage-platform'], $user);

-    $organizationResponse = $this->actingAs($user)
+    $organizationResponse = $this->withToken($token)
         ->postJson('/api/admin/v1/platform/organizations', [
@@
-    $this->actingAs($user)
+    $this->withToken($token)
         ->postJson('/api/admin/v1/platform/stores', [
@@
-test('platform api rejects non owner sessions and tokens without manage platform ability', function (): void {
+test('platform api rejects non owner tokens and tokens without manage platform ability', function (): void {
@@
     $readToken = adminPlatformApiToken($store, ['read-products']);
+    $staffToken = adminApiBearerToken($store, ['manage-platform'], $staff);

-    $this->actingAs($staff)
+    $this->withToken($staffToken)
         ->postJson('/api/admin/v1/platform/organizations', [
@@
-    $user = adminPlatformApiUser();
+    $user = adminPlatformApiUser();
@@
     $readToken = adminPlatformApiToken($store, ['read-products']);
     $manageToken = adminPlatformApiToken($store, ['manage-platform']);
+    $userToken = adminApiBearerToken($store, ['*'], $user);
+    $staffToken = adminApiBearerToken($store, ['manage-platform'], $staff);
@@
-    $this->actingAs($user)
+    $this->withToken($userToken)
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/me")
@@
-    $this->actingAs($staff)
+    $this->withToken($staffToken)
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [
@@
-    $this->actingAs($user)
+    $this->withToken($manageToken['plain_text'])
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [
*** End Patch


stdout:
Success. Updated the following files:
M tests/Feature/Api/AdminPlatformApiTest.php

unified_diff:
@@ -36,6 +36,8 @@

-test('platform api creates organizations and stores for owner sessions', function (): void {
+test('platform api creates organizations and stores for owner bearer tokens', function (): void {
+    $store = adminPlatformApiStore();
     $user = adminPlatformApiUser();
+    $token = adminApiBearerToken($store, ['manage-platform'], $user);

-    $organizationResponse = $this->actingAs($user)
+    $organizationResponse = $this->withToken($token)
         ->postJson('/api/admin/v1/platform/organizations', [
@@ -50,3 +52,3 @@

-    $this->actingAs($user)
+    $this->withToken($token)
         ->postJson('/api/admin/v1/platform/stores', [
@@ -74,3 +76,3 @@

-test('platform api rejects non owner sessions and tokens without manage platform ability', function (): void {
+test('platform api rejects non owner tokens and tokens without manage platform ability', function (): void {
     $store = adminPlatformApiStore();
@@ -86,4 +88,5 @@
     $readToken = adminPlatformApiToken($store, ['read-products']);
+    $staffToken = adminApiBearerToken($store, ['manage-platform'], $staff);

-    $this->actingAs($staff)
+    $this->withToken($staffToken)
         ->postJson('/api/admin/v1/platform/organizations', [
@@ -131,2 +134,4 @@
     $manageToken = adminPlatformApiToken($store, ['manage-platform']);
+    $userToken = adminApiBearerToken($store, ['*'], $user);
+    $staffToken = adminApiBearerToken($store, ['manage-platform'], $staff);

@@ -148,3 +153,3 @@

-    $this->actingAs($user)
+    $this->withToken($userToken)
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/me")
@@ -155,3 +160,3 @@

-    $this->actingAs($staff)
+    $this->withToken($staffToken)
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [
@@ -162,3 +167,3 @@

-    $this->actingAs($user)
+    $this->withToken($manageToken['plain_text'])
         ->postJson("/api/admin/v1/stores/{$store->getKey()}/invites", [


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


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


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


cmd: sed -n '1,260p' tests/Feature/Api/AdminOrderApiTest.php


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


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

aggregated_output:
<?php

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

uses(RefreshDatabase::class);

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

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

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

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

test('admin collection api lists creates updates and deletes collections', function (): void {
    $store = adminCollectionApiStore();
    $products = Product::factory()
        ->count(3)
        ->withDefaultVariant()
        ->create(['store_id' => $store->getKey()]);
    Collection::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
        'title' => 'Other Store Collection',
    ]);
    $user = adminCollectionApiUser();

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=New")
        ->assertOk()
        ->assertJsonMissing(['title' => 'Other Store Collection']);

    $createResponse = $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
            'title' => 'API Winter',
            'description_html' => '
Cold weather goods.
',
            'type' => 'manual',
            'status' => 'active',
            'product_ids' => $products->take(2)->pluck('id')->all(),
        ])
        ->assertCreated()
        ->assertJsonPath('data.title', 'API Winter')
        ->assertJsonPath('data.handle', 'api-winter')
        ->assertJsonPath('data.products_count', 2);

    $collectionId = $createResponse->json('data.id');
    $collection = Collection::withoutGlobalScopes()->findOrFail($collectionId);

    expect($collection->products()->pluck('collection_products.position', 'products.id')->all())
        ->toBe([
            $products[0]->getKey() => 0,
            $products[1]->getKey() => 1,
        ]);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}", [
            'title' => 'API Winter Edit',
            'status' => 'draft',
            'add_product_ids' => [$products[2]->getKey()],
            'remove_product_ids' => [$products[0]->getKey()],
        ])
        ->assertOk()
        ->assertJsonPath('data.title', 'API Winter Edit')
        ->assertJsonPath('data.status', 'draft')
        ->assertJsonPath('data.products_count', 2);

    expect($collection->refresh()->products()->pluck('products.id')->all())
        ->toBe([$products[1]->getKey(), $products[2]->getKey()]);

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

    expect(Collection::withoutGlobalScopes()->whereKey($collection->getKey())->exists())->toBeFalse();
});

test('admin collection api enforces token abilities and store scope', function (): void {
    $store = adminCollectionApiStore();
    $otherStore = Store::factory()->create();
    $collection = Collection::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'Token Collection',
    ]);
    $readToken = adminCollectionApiToken($store, ['read-collections']);
    $writeToken = adminCollectionApiToken($store, ['write-collections']);
    $otherStoreToken = adminCollectionApiToken($otherStore, ['read-collections']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=Token")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Token Collection');

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

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

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

test('admin collection api validates handles and product store ownership', function (): void {
    $store = adminCollectionApiStore();
    $existing = Collection::factory()->create([
        'store_id' => $store->getKey(),
        'handle' => 'existing-api-collection',
    ]);
    $otherStoreProduct = Product::factory()
        ->withDefaultVariant()
        ->create(['store_id' => Store::factory()->create()->getKey()]);

    $this->actingAs(adminCollectionApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
            'title' => 'Invalid API Collection',
            'handle' => $existing->handle,
            'type' => 'manual',
            'product_ids' => [$otherStoreProduct->getKey()],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['handle', 'product_ids.0']);
});


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

aggregated_output:
<?php

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

uses(RefreshDatabase::class);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

aggregated_output:
<?php

use App\Models\DataExport;
use App\Models\Fulfillment;
use App\Models\Order;
use App\Models\Store;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

uses(RefreshDatabase::class);

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

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

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

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

function adminOrderExportApiOrder(Store $store, array $attributes = []): Order
{
    return Order::factory()->paid()->create([
        'store_id' => $store->getKey(),
        'email' => 'export@example.test',
        'subtotal_amount' => 2500,
        'discount_amount' => 100,
        'shipping_amount' => 500,
        'tax_amount' => 0,
        'total_amount' => 2900,
        'currency' => $store->default_currency,
        ...$attributes,
    ]);
}

test('admin order export api creates completed csv exports', function (): void {
    Storage::fake('local');

    $store = adminOrderExportApiStore();
    $matching = adminOrderExportApiOrder($store, [
        'order_number' => '#EX1001',
        'created_at' => now()->subDays(2),
    ]);
    Fulfillment::factory()->shipped()->create([
        'order_id' => $matching->getKey(),
        'tracking_number' => 'TRACK-1001',
    ]);
    adminOrderExportApiOrder($store, [
        'order_number' => '#EX2001',
        'status' => 'pending',
        'financial_status' => 'pending',
        'created_at' => now()->subDays(2),
    ]);
    adminOrderExportApiOrder(Store::factory()->create(), [
        'order_number' => '#EX3001',
        'created_at' => now()->subDays(2),
    ]);

    $createResponse = $this->actingAs(adminOrderExportApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/exports/orders", [
            'format' => 'csv',
            'filters' => [
                'status' => 'paid',
                'created_after' => now()->subDays(3)->toIso8601String(),
                'created_before' => now()->subDay()->toIso8601String(),
            ],
        ])
        ->assertAccepted()
        ->assertJsonPath('status', 'completed');

    $export = DataExport::query()->findOrFail($createResponse->json('export_id'));

    Storage::disk('local')->assertExists($export->storage_key);

    $showResponse = $this->actingAs(adminOrderExportApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/exports/{$export->getKey()}")
        ->assertOk()
        ->assertJsonPath('data.status', 'completed')
        ->assertJsonPath('data.format', 'csv')
        ->assertJsonPath('data.row_count', 1)
        ->assertJsonPath('data.download_expires_at', $export->download_expires_at?->toIso8601String());

    $csv = rawurldecode(Str::after((string) $showResponse->json('data.download_url'), ','));

    expect($csv)->toContain('order_number,created_at,status')
        ->and($csv)->toContain('#EX1001')
        ->and($csv)->toContain('TRACK-1001')
        ->and($csv)->not->toContain('#EX2001')
        ->and($csv)->not->toContain('#EX3001');
});

test('admin order export api enforces token abilities and store scope', function (): void {
    Storage::fake('local');

    $store = adminOrderExportApiStore();
    adminOrderExportApiOrder($store, ['order_number' => '#TOKEN-EXPORT']);
    $readToken = adminOrderExportApiToken($store, ['read-orders']);
    $wrongAbilityToken = adminOrderExportApiToken($store, ['read-products']);
    $otherStoreToken = adminOrderExportApiToken(Store::factory()->create(), ['read-orders']);

    $this->withToken($wrongAbilityToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/exports/orders", [
            'format' => 'csv',
        ])
        ->assertForbidden();

    $createResponse = $this->withToken($readToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/exports/orders", [
            'format' => 'csv',
        ])
        ->assertAccepted();

    $export = DataExport::query()->findOrFail($createResponse->json('export_id'));

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/exports/{$export->getKey()}")
        ->assertOk();

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

    $otherStoreExport = DataExport::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
    ]);

    $this->actingAs(adminOrderExportApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/exports/{$otherStoreExport->getKey()}")
        ->assertNotFound();
});

test('admin order export api validates format and filters', function (): void {
    $store = adminOrderExportApiStore();

    $this->actingAs(adminOrderExportApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/exports/orders", [
            'format' => 'xlsx',
            'filters' => [
                'status' => 'lost',
                'created_after' => now()->toIso8601String(),
                'created_before' => now()->subDay()->toIso8601String(),
            ],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['format', 'filters.status', 'filters.created_before']);
});


command: sed -n '1,260p' tests/Feature/Api/AdminOrderApiTest.php

aggregated_output:
<?php

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\PaymentStatus;
use App\Models\Fulfillment;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\OrderLine;
use App\Models\Payment;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

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

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

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

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

/**
 * @return array{0: Order, 1: OrderLine, 2: ProductVariant}
 */
function adminOrderApiOrder(Store $store, array $orderAttributes = [], int $quantity = 2, int $unitPrice = 2500): array
{
    $product = Product::factory()
        ->withDefaultVariant($unitPrice)
        ->create(['store_id' => $store->getKey()]);
    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();

    InventoryItem::withoutGlobalScopes()
        ->where('variant_id', $variant->getKey())
        ->update([
            'quantity_on_hand' => 10,
            'quantity_reserved' => 0,
        ]);

    $total = $quantity * $unitPrice;
    $order = Order::factory()->paid()->create(array_merge([
        'store_id' => $store->getKey(),
        'subtotal_amount' => $total,
        'shipping_amount' => 0,
        'tax_amount' => 0,
        'total_amount' => $total,
        'email' => 'buyer@example.test',
    ], $orderAttributes));
    $line = OrderLine::factory()->create([
        'order_id' => $order->getKey(),
        'product_id' => $product->getKey(),
        'variant_id' => $variant->getKey(),
        'title_snapshot' => 'Admin API Product',
        'sku_snapshot' => 'ADMIN-API-001',
        'quantity' => $quantity,
        'unit_price_amount' => $unitPrice,
        'total_amount' => $total,
    ]);

    Payment::factory()->create([
        'order_id' => $order->getKey(),
        'status' => $order->financial_status === FinancialStatus::Pending ? PaymentStatus::Pending : PaymentStatus::Captured,
        'amount' => $total,
        'currency' => $order->currency,
    ]);

    return [$order, $line, $variant];
}

test('admin order api lists and shows store scoped orders', function (): void {
    $store = adminOrderApiStore();
    [$order] = adminOrderApiOrder($store, [
        'order_number' => '#8001',
        'email' => 'alpha@example.test',
    ]);
    $otherStore = Store::factory()->create();
    adminOrderApiOrder($otherStore, [
        'order_number' => '#9001',
        'email' => 'other@example.test',
    ]);

    $this->getJson("/api/admin/v1/stores/{$store->getKey()}/orders")
        ->assertUnauthorized();

    $this->actingAs(adminOrderApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/orders?query=alpha")
        ->assertOk()
        ->assertJsonPath('data.0.order_number', '#8001')
        ->assertJsonMissing(['order_number' => '#9001']);

    $this->actingAs(adminOrderApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}")
        ->assertOk()
        ->assertJsonPath('data.order_number', '#8001')
        ->assertJsonPath('data.lines.0.title', 'Admin API Product');
});

test('admin order api creates refunds and fulfillments', function (): void {
    $store = adminOrderApiStore();
    [$order, $line] = adminOrderApiOrder($store, quantity: 2, unitPrice: 2500);
    $user = adminOrderApiUser();

    $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}/refunds", [
            'amount' => 1000,
            'reason' => 'Customer return',
        ])
        ->assertCreated()
        ->assertJsonPath('data.amount', 1000)
        ->assertJsonPath('data.status', 'processed');

    expect($order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded);

    $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}/fulfillments", [
            'line_items' => [
                ['order_line_id' => $line->getKey(), 'quantity' => 1],
            ],
            'tracking_company' => 'DHL',
            'tracking_number' => 'DHL123',
        ])
        ->assertCreated()
        ->assertJsonPath('data.status', 'pending')
        ->assertJsonPath('data.lines.0.quantity', 1);

    $fulfillment = Fulfillment::query()->where('order_id', $order->getKey())->firstOrFail();

    expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial)
        ->and($fulfillment->tracking_number)->toBe('DHL123');
});

test('admin order api rejects orders outside the requested store', function (): void {
    $store = adminOrderApiStore();
    $otherStore = Store::factory()->create();
    [$otherOrder] = adminOrderApiOrder($otherStore, [
        'order_number' => '#9001',
    ]);

    $this->actingAs(adminOrderApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$otherOrder->getKey()}")
        ->assertNotFound();
});

test('admin order api accepts scoped bearer tokens', function (): void {
    $store = adminOrderApiStore();
    adminOrderApiOrder($store, [
        'order_number' => '#8101',
        'email' => 'token@example.test',
    ]);
    $result = adminOrderApiToken($store, ['read-orders']);

    $this->withToken($result['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/orders?query=token")
        ->assertOk()
        ->assertJsonPath('data.0.order_number', '#8101');

    expect($result['token']->refresh()->last_used_at)->not->toBeNull();
});

test('admin order api enforces token store scope and abilities', function (): void {
    $store = adminOrderApiStore();
    [$order] = adminOrderApiOrder($store);
    $readOnly = adminOrderApiToken($store, ['read-orders']);
    $writeToken = adminOrderApiToken($store, ['write-orders']);
    $otherStore = Store::factory()->create();
    $otherStoreToken = adminOrderApiToken($otherStore, ['read-orders']);

    $this->withToken($readOnly['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}/refunds", [
            'amount' => 500,
            'reason' => 'Read-only token',
        ])
        ->assertForbidden();

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

    $this->withToken($writeToken['plain_text'])
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/orders/{$order->getKey()}/refunds", [
            'amount' => 500,
            'reason' => 'Write token',
        ])
        ->assertCreated()
        ->assertJsonPath('data.amount', 500);
});

test('admin order api rejects expired bearer tokens', function (): void {
    $store = adminOrderApiStore();
    adminOrderApiOrder($store);
    $result = adminOrderApiToken($store, ['read-orders']);
    $result['token']->forceFill(['expires_at' => now()->subMinute()])->save();

    $this->withToken($result['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/orders")
        ->assertUnauthorized();
});


output:
Chunk ID: 5468df
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1386
Output:
<?php

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

uses(RefreshDatabase::class);

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

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

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

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

test('admin collection api lists creates updates and deletes collections', function (): void {
    $store = adminCollectionApiStore();
    $products = Product::factory()
        ->count(3)
        ->withDefaultVariant()
        ->create(['store_id' => $store->getKey()]);
    Collection::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
        'title' => 'Other Store Collection',
    ]);
    $user = adminCollectionApiUser();

    $this->actingAs($user)
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=New")
        ->assertOk()
        ->assertJsonMissing(['title' => 'Other Store Collection']);

    $createResponse = $this->actingAs($user)
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
            'title' => 'API Winter',
            'description_html' => '
Cold weather goods.
',
            'type' => 'manual',
            'status' => 'active',
            'product_ids' => $products->take(2)->pluck('id')->all(),
        ])
        ->assertCreated()
        ->assertJsonPath('data.title', 'API Winter')
        ->assertJsonPath('data.handle', 'api-winter')
        ->assertJsonPath('data.products_count', 2);

    $collectionId = $createResponse->json('data.id');
    $collection = Collection::withoutGlobalScopes()->findOrFail($collectionId);

    expect($collection->products()->pluck('collection_products.position', 'products.id')->all())
        ->toBe([
            $products[0]->getKey() => 0,
            $products[1]->getKey() => 1,
        ]);

    $this->actingAs($user)
        ->putJson("/api/admin/v1/stores/{$store->getKey()}/collections/{$collection->getKey()}", [
            'title' => 'API Winter Edit',
            'status' => 'draft',
            'add_product_ids' => [$products[2]->getKey()],
            'remove_product_ids' => [$products[0]->getKey()],
        ])
        ->assertOk()
        ->assertJsonPath('data.title', 'API Winter Edit')
        ->assertJsonPath('data.status', 'draft')
        ->assertJsonPath('data.products_count', 2);

    expect($collection->refresh()->products()->pluck('products.id')->all())
        ->toBe([$products[1]->getKey(), $products[2]->getKey()]);

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

    expect(Collection::withoutGlobalScopes()->whereKey($collection->getKey())->exists())->toBeFalse();
});

test('admin collection api enforces token abilities and store scope', function (): void {
    $store = adminCollectionApiStore();
    $otherStore = Store::factory()->create();
    $collection = Collection::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'Token Collection',
    ]);
    $readToken = adminCollectionApiToken($store, ['read-collections']);
    $writeToken = adminCollectionApiToken($store, ['write-collections']);
    $otherStoreToken = adminCollectionApiToken($otherStore, ['read-collections']);

    $this->withToken($readToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/collections?query=Token")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Token Collection');

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

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

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

test('admin collection api validates handles and product store ownership', function (): void {
    $store = adminCollectionApiStore();
    $existing = Collection::factory()->create([
        'store_id' => $store->getKey(),
        'handle' => 'existing-api-collection',
    ]);
    $otherStoreProduct = Product::factory()
        ->withDefaultVariant()
        ->create(['store_id' => Store::factory()->create()->getKey()]);

    $this->actingAs(adminCollectionApiUser())
        ->postJson("/api/admin/v1/stores/{$store->getKey()}/collections", [
            'title' => 'Invalid API Collection',
            'handle' => $existing->handle,
            'type' => 'manual',
            'product_ids' => [$otherStoreProduct->getKey()],
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['handle', 'product_ids.0']);
});


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

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

uses(RefreshDatabase::class);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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