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

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

use App\Exceptions\CartVersionMismatchException;
use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidCartOperationException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Storefront\V1\DestroyCartLineRequest;
use App\Http\Requests\Api\Storefront\V1\StoreCartLineRequest;
use App\Http\Requests\Api\Storefront\V1\UpdateCartLineRequest;
use App\Http\Resources\Storefront\V1\CartResource;
use App\Models\Cart;
use App\Models\CartLine;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;

class CartLineController extends Controller
{
    public function store(StoreCartLineRequest $request, Cart $cart, CartService $carts): CartResource|JsonResponse
    {
        try {
            $carts->addLine(
                $cart,
                (int) $request->validated('variant_id'),
                (int) $request->validated('quantity'),
                $request->expectedVersion(),
            );

            return CartResource::make($this->loadCart($cart->refresh()))
                ->response()
                ->setStatusCode(201);
        } catch (CartVersionMismatchException $exception) {
            return $this->versionMismatch($exception, $cart);
        } catch (InsufficientInventoryException|InvalidCartOperationException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function update(UpdateCartLineRequest $request, Cart $cart, CartLine $cartLine, CartService $carts): CartResource|JsonResponse
    {
        abort_unless((int) $cartLine->cart_id === (int) $cart->getKey(), 404);

        try {
            $carts->updateLineQuantity(
                $cart,
                $cartLine->getKey(),
                (int) $request->validated('quantity'),
                $request->expectedVersion(),
            );

            return CartResource::make($this->loadCart($cart->refresh()));
        } catch (CartVersionMismatchException $exception) {
            return $this->versionMismatch($exception, $cart);
        } catch (InsufficientInventoryException|InvalidCartOperationException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }
    }

    public function destroy(DestroyCartLineRequest $request, Cart $cart, CartLine $cartLine, CartService $carts): CartResource|JsonResponse
    {
        abort_unless((int) $cartLine->cart_id === (int) $cart->getKey(), 404);

        try {
            $carts->removeLine($cart, $cartLine->getKey(), $request->expectedVersion());
        } catch (CartVersionMismatchException $exception) {
            return $this->versionMismatch($exception, $cart);
        } catch (InvalidCartOperationException $exception) {
            return response()->json(['message' => $exception->getMessage()], 422);
        }

        return CartResource::make($this->loadCart($cart->refresh()));
    }

    private function versionMismatch(CartVersionMismatchException $exception, Cart $cart): JsonResponse
    {
        return response()->json([
            'message' => $exception->getMessage(),
            'expected_cart_version' => $exception->expectedVersion,
            'current_cart_version' => $exception->currentVersion,
            'cart' => CartResource::make($this->loadCart($cart->refresh())),
        ], 409);
    }

    private function loadCart(Cart $cart): Cart
    {
        return $cart->load([
            'lines.variant.product',
            'lines.variant.optionValues.option',
        ]);
    }
}


output:
Chunk ID: 3ee69f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1956
Output:
tests/Feature/Api/StorefrontCheckoutApiTest.php:82:            'cart_id' => $cartId,
tests/Feature/Api/StorefrontCheckoutApiTest.php:162:            'cart_id' => $cartId,
tests/Feature/Api/StorefrontOrderApiTest.php:70:            'cart_id' => $cartId,
tests/Feature/Api/StorefrontOrderApiTest.php:116:    $token = $payResponse['data']['access_token'];
app/Services/DiscountService.php:164:            ->where('cart_id', $cart->getKey())
app/Services/ShippingCalculator.php:111:            ->where('cart_id', $cart->getKey())
app/Services/ShippingCalculator.php:132:            ->where('cart_id', $cart->getKey())
app/Services/PricingEngine.php:34:                ->where('cart_id', $cart->getKey())
app/Services/PricingEngine.php:55:                ->where('cart_id', $cart->getKey())
app/Services/PricingEngine.php:94:            ->where('cart_id', $checkout->cart_id)
app/Models/OauthToken.php:22:        'access_token_hash',
app/Services/CartService.php:37:        $sessionCartId = session('cart_id');
app/Services/CartService.php:58:                session()->forget('cart_id');
app/Services/CartService.php:77:        session(['cart_id' => $cart->getKey()]);
app/Services/CartService.php:97:        $sessionCartId = session('cart_id');
app/Services/CartService.php:131:                ->where('cart_id', $lockedCart->getKey())
app/Services/CartService.php:140:                    'cart_id' => $lockedCart->getKey(),
app/Services/CartService.php:168:                ->where('cart_id', $lockedCart->getKey())
app/Services/CartService.php:189:                ->where('cart_id', $lockedCart->getKey())
app/Services/CartService.php:204:                ->where('cart_id', $guestCart->getKey())
app/Services/CartService.php:208:                        ->where('cart_id', $customerCart->getKey())
app/Services/CartService.php:221:                    $guestLine->forceFill(['cart_id' => $customerCart->getKey()])->save();
app/Models/CartLine.php:21:        'cart_id',
app/Http/Controllers/Api/Admin/V1/PageController.php:103:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Services/CheckoutService.php:40:            if (! CartLine::withoutGlobalScopes()->where('cart_id', $cart->getKey())->exists()) {
app/Services/CheckoutService.php:45:                ->where('cart_id', $cart->getKey())
app/Services/CheckoutService.php:57:                'cart_id' => $cart->getKey(),
app/Services/CheckoutService.php:162:                ->where('cart_id', $checkout->cart_id)
app/Services/CheckoutService.php:193:                    ->where('cart_id', $checkout->cart_id)
app/Http/Controllers/Api/Admin/V1/ThemeSettingsController.php:46:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Services/OrderService.php:487:            ->where('cart_id', $checkout->cart_id)
app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php:60:        if (! $request->attributes->has('sanctum_personal_access_token')) {
database/migrations/2026_05_04_034611_create_oauth_tokens_table.php:18:            $table->string('access_token_hash')->unique('idx_oauth_tokens_access_hash');
app/Http/Controllers/Api/Admin/V1/TaxSettingsController.php:59:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/CustomerController.php:56:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/OrderRefundController.php:46:        if (! $request->attributes->has('sanctum_personal_access_token')) {
database/migrations/2026_05_03_225405_create_cart_lines_table.php:16:            $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
database/migrations/2026_05_03_225405_create_cart_lines_table.php:24:            $table->index('cart_id');
database/migrations/2026_05_03_225405_create_cart_lines_table.php:25:            $table->unique(['cart_id', 'variant_id']);
app/Http/Controllers/Api/Admin/V1/StoreSettingsController.php:90:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/ProductController.php:231:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Requests/Api/Storefront/V1/StoreCheckoutRequest.php:25:            'cart_id' => ['required', 'integer', 'exists:carts,id'],
app/Http/Controllers/Api/Admin/V1/ThemeController.php:67:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/DiscountController.php:77:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/OrderFulfillmentController.php:40:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/StoreMembershipController.php:16:        $token = $request->attributes->get('sanctum_personal_access_token');
app/Http/Controllers/Api/Admin/V1/SearchIndexController.php:68:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Models/Checkout.php:21:        'cart_id',
app/Http/Controllers/Api/Admin/V1/CollectionController.php:110:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/ShippingRateController.php:54:        if (! $request->attributes->has('sanctum_personal_access_token')) {
database/migrations/2026_05_03_225409_create_checkouts_table.php:17:            $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
database/migrations/2026_05_03_225409_create_checkouts_table.php:32:            $table->index('cart_id');
app/Http/Controllers/Api/Admin/V1/OrderController.php:62:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Storefront/V1/CartLineController.php:42:        abort_unless((int) $cartLine->cart_id === (int) $cart->getKey(), 404);
app/Http/Controllers/Api/Storefront/V1/CartLineController.php:62:        abort_unless((int) $cartLine->cart_id === (int) $cart->getKey(), 404);
database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php:14:        Schema::create('personal_access_tokens', function (Blueprint $table) {
database/migrations/2026_05_04_180719_create_personal_access_tokens_table.php:31:        Schema::dropIfExists('personal_access_tokens');
app/Http/Controllers/Api/Admin/V1/StoreInviteController.php:42:        if ($request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Storefront/V1/CheckoutController.php:33:        $cart = Cart::query()->findOrFail($request->validated('cart_id'));
app/Http/Controllers/Api/Admin/V1/OrderExportController.php:50:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Controllers/Api/Admin/V1/AnalyticsSummaryController.php:66:        if (! $request->attributes->has('sanctum_personal_access_token')) {
app/Http/Middleware/AuthenticatePlatformApi.php:46:        $token = $request->attributes->get('sanctum_personal_access_token');
app/Http/Middleware/AuthenticatePlatformApi.php:67:        $request->attributes->set('sanctum_personal_access_token', $token);
app/Http/Middleware/AuthenticatePlatformApi.php:68:        app()->instance('sanctum_personal_access_token', $token);
app/Http/Middleware/AuthenticateAdminApi.php:59:        $token = $request->attributes->get('sanctum_personal_access_token');
app/Http/Middleware/AuthenticateAdminApi.php:80:        $request->attributes->set('sanctum_personal_access_token', $token);
app/Http/Middleware/AuthenticateAdminApi.php:81:        app()->instance('sanctum_personal_access_token', $token);
app/Http/Resources/Storefront/V1/OrderResource.php:22:            'access_token' => OrderAccessToken::make($this->resource),
app/Http/Resources/Storefront/V1/CheckoutResource.php:20:            'cart_id' => $this->cart_id,


cmd: sed -n '1,130p' app/Services/CartService.php && sed -n '1,80p' app/Http/Requests/Api/Storefront/V1/StoreCheckoutRequest.php


command: sed -n '1,130p' app/Services/CartService.php && sed -n '1,80p' app/Http/Requests/Api/Storefront/V1/StoreCheckoutRequest.php

CartService.php

type: read

StoreCheckoutRequest.php

aggregated_output:
<?php

namespace App\Services;

use App\Enums\CartStatus;
use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Exceptions\CartVersionMismatchException;
use App\Exceptions\InvalidCartOperationException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Support\Facades\DB;

class CartService
{
    public function __construct(
        private readonly InventoryService $inventory,
    ) {}

    public function create(Store $store, ?Customer $customer = null): Cart
    {
        return Cart::withoutGlobalScopes()->create([
            'store_id' => $store->getKey(),
            'customer_id' => $customer?->getKey(),
            'currency' => $store->default_currency,
            'cart_version' => 1,
            'status' => CartStatus::Active,
        ]);
    }

    public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart
    {
        $sessionCartId = session('cart_id');

        if ($customer instanceof Customer) {
            $customerCart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->where('customer_id', $customer->getKey())
                ->where('status', CartStatus::Active)
                ->latest('id')
                ->first() ?? $this->create($store, $customer);

            if ($sessionCartId) {
                $guestCart = Cart::withoutGlobalScopes()
                    ->where('store_id', $store->getKey())
                    ->whereNull('customer_id')
                    ->where('status', CartStatus::Active)
                    ->find($sessionCartId);

                if ($guestCart instanceof Cart && $guestCart->isNot($customerCart)) {
                    $customerCart = $this->mergeOnLogin($guestCart, $customerCart);
                }

                session()->forget('cart_id');
            }

            return $customerCart->refresh();
        }

        if ($sessionCartId) {
            $cart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->whereNull('customer_id')
                ->where('status', CartStatus::Active)
                ->find($sessionCartId);

            if ($cart instanceof Cart) {
                return $cart;
            }
        }

        $cart = $this->create($store);
        session(['cart_id' => $cart->getKey()]);

        return $cart;
    }

    public function currentForSession(Store $store, ?Customer $customer = null): ?Cart
    {
        if ($customer instanceof Customer) {
            $customerCart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->where('customer_id', $customer->getKey())
                ->where('status', CartStatus::Active)
                ->latest('id')
                ->first();

            if ($customerCart instanceof Cart) {
                return $customerCart;
            }
        }

        $sessionCartId = session('cart_id');

        if (! $sessionCartId) {
            return null;
        }

        return Cart::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('status', CartStatus::Active)
            ->where(function ($query) use ($customer): void {
                if ($customer instanceof Customer) {
                    $query
                        ->whereNull('customer_id')
                        ->orWhere('customer_id', $customer->getKey());

                    return;
                }

                $query->whereNull('customer_id');
            })
            ->find($sessionCartId);
    }

    public function addLine(Cart $cart, int $variantId, int $quantity, ?int $expectedVersion = null): CartLine
    {
        $this->assertPositiveQuantity($quantity);

        return DB::transaction(function () use ($cart, $variantId, $quantity, $expectedVersion): CartLine {
            $lockedCart = $this->freshCart($cart);
            $this->assertExpectedVersion($lockedCart, $expectedVersion);
            $this->assertCartIsActive($lockedCart);

            $variant = $this->purchasableVariant($lockedCart, $variantId);
            $line = CartLine::withoutGlobalScopes()
<?php

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

use Illuminate\Foundation\Http\FormRequest;

class StoreCheckoutRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

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

status: completed


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

namespace App\Services;

use App\Enums\CartStatus;
use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Exceptions\CartVersionMismatchException;
use App\Exceptions\InvalidCartOperationException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Support\Facades\DB;

class CartService
{
    public function __construct(
        private readonly InventoryService $inventory,
    ) {}

    public function create(Store $store, ?Customer $customer = null): Cart
    {
        return Cart::withoutGlobalScopes()->create([
            'store_id' => $store->getKey(),
            'customer_id' => $customer?->getKey(),
            'currency' => $store->default_currency,
            'cart_version' => 1,
            'status' => CartStatus::Active,
        ]);
    }

    public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart
    {
        $sessionCartId = session('cart_id');

        if ($customer instanceof Customer) {
            $customerCart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->where('customer_id', $customer->getKey())
                ->where('status', CartStatus::Active)
                ->latest('id')
                ->first() ?? $this->create($store, $customer);

            if ($sessionCartId) {
                $guestCart = Cart::withoutGlobalScopes()
                    ->where('store_id', $store->getKey())
                    ->whereNull('customer_id')
                    ->where('status', CartStatus::Active)
                    ->find($sessionCartId);

                if ($guestCart instanceof Cart && $guestCart->isNot($customerCart)) {
                    $customerCart = $this->mergeOnLogin($guestCart, $customerCart);
                }

                session()->forget('cart_id');
            }

            return $customerCart->refresh();
        }

        if ($sessionCartId) {
            $cart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->whereNull('customer_id')
                ->where('status', CartStatus::Active)
                ->find($sessionCartId);

            if ($cart instanceof Cart) {
                return $cart;
            }
        }

        $cart = $this->create($store);
        session(['cart_id' => $cart->getKey()]);

        return $cart;
    }

    public function currentForSession(Store $store, ?Customer $customer = null): ?Cart
    {
        if ($customer instanceof Customer) {
            $customerCart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->where('customer_id', $customer->getKey())
                ->where('status', CartStatus::Active)
                ->latest('id')
                ->first();

            if ($customerCart instanceof Cart) {
                return $customerCart;
            }
        }

        $sessionCartId = session('cart_id');

        if (! $sessionCartId) {
            return null;
        }

        return Cart::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('status', CartStatus::Active)
            ->where(function ($query) use ($customer): void {
                if ($customer instanceof Customer) {
                    $query
                        ->whereNull('customer_id')
                        ->orWhere('customer_id', $customer->getKey());

                    return;
                }

                $query->whereNull('customer_id');
            })
            ->find($sessionCartId);
    }

    public function addLine(Cart $cart, int $variantId, int $quantity, ?int $expectedVersion = null): CartLine
    {
        $this->assertPositiveQuantity($quantity);

        return DB::transaction(function () use ($cart, $variantId, $quantity, $expectedVersion): CartLine {
            $lockedCart = $this->freshCart($cart);
            $this->assertExpectedVersion($lockedCart, $expectedVersion);
            $this->assertCartIsActive($lockedCart);

            $variant = $this->purchasableVariant($lockedCart, $variantId);
            $line = CartLine::withoutGlobalScopes()
<?php

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

use Illuminate\Foundation\Http\FormRequest;

class StoreCheckoutRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

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


cmd: rg -n "checkout|cart|access token|token|cart_id|checkout_id" specs/02-API-ROUTES.md specs/04-STOREFRONT-UI.md specs/05-BUSINESS-LOGIC.md specs/06-AUTH-AND-SECURITY.md


cmd: sed -n '1,140p' app/Http/Resources/Storefront/V1/CheckoutResource.php && sed -n '1,100p' app/Http/Resources/Storefront/V1/CartResource.php


cmd: sed -n '1,80p' database/migrations/2026_05_03_225400_create_carts_table.php && sed -n '1,90p' database/migrations/2026_05_03_225409_create_checkouts_table.php


command: sed -n '1,140p' app/Http/Resources/Storefront/V1/CheckoutResource.php && sed -n '1,100p' app/Http/Resources/Storefront/V1/CartResource.php

CartResource.php

aggregated_output:
<?php

namespace App\Http\Resources\Storefront\V1;

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

class CheckoutResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'cart_id' => $this->cart_id,
            'customer_id' => $this->customer_id,
            'status' => $this->status?->value,
            'payment_method' => $this->payment_method,
            'email' => $this->email,
            'shipping_address' => $this->shipping_address_json,
            'billing_address' => $this->billing_address_json,
            'shipping_method_id' => $this->shipping_method_id,
            'discount_code' => $this->discount_code,
            'totals' => $this->totals_json,
            'tax_provider_snapshot' => $this->tax_provider_snapshot_json,
            'available_shipping_rates' => ShippingRateResource::collection($this->whenLoaded('availableRates')),
            'cart' => new CartResource($this->whenLoaded('cart')),
            'expires_at' => $this->expires_at?->toIso8601String(),
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}
<?php

namespace App\Http\Resources\Storefront\V1;

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

class CartResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        $lines = $this->whenLoaded('lines');

        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'customer_id' => $this->customer_id,
            'currency' => $this->currency,
            'status' => $this->status?->value,
            'cart_version' => $this->cart_version,
            'line_count' => $this->relationLoaded('lines') ? $this->lines->sum('quantity') : null,
            'totals' => $this->relationLoaded('lines') ? [
                'subtotal' => $this->lines->sum('line_subtotal_amount'),
                'discount' => $this->lines->sum('line_discount_amount'),
                'total' => $this->lines->sum('line_total_amount'),
                'currency' => $this->currency,
            ] : null,
            'lines' => CartLineResource::collection($lines),
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}


command: sed -n '1,80p' database/migrations/2026_05_03_225400_create_carts_table.php && sed -n '1,90p' database/migrations/2026_05_03_225409_create_checkouts_table.php

aggregated_output:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('carts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
            $table->string('currency', 3)->default('USD');
            $table->unsignedInteger('cart_version')->default(1);
            $table->enum('status', ['active', 'converted', 'abandoned'])->default('active');
            $table->timestamps();

            $table->index('store_id');
            $table->index('customer_id');
            $table->index(['store_id', 'status']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('carts');
    }
};
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('checkouts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
            $table->enum('status', ['started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired'])->default('started');
            $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer'])->nullable();
            $table->string('email')->nullable();
            $table->text('shipping_address_json')->nullable();
            $table->text('billing_address_json')->nullable();
            $table->unsignedBigInteger('shipping_method_id')->nullable();
            $table->string('discount_code')->nullable();
            $table->text('tax_provider_snapshot_json')->nullable();
            $table->text('totals_json')->nullable();
            $table->timestamp('expires_at')->nullable();
            $table->timestamps();

            $table->index('store_id');
            $table->index('cart_id');
            $table->index('customer_id');
            $table->index(['store_id', 'status']);
            $table->index('expires_at');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('checkouts');
    }
};


command: rg -n "checkout|cart|access token|token|cart_id|checkout_id" specs/02-API-ROUTES.md specs/04-STOREFRONT-UI.md specs/05-BUSINESS-LOGIC.md specs/06-AUTH-AND-SECURITY.md

type: search

cmd: rg -n 'checkout|cart|access token|token|cart_id|checkout_id' specs/02-API-ROUTES.md specs/04-STOREFRONT-UI.md specs/05-BUSINESS-LOGIC.md specs/06-AUTH-AND-SECURITY.md

query: checkout|cart|access token|token|cart_id|checkout_id

aggregated_output:
specs/06-AUTH-AND-SECURITY.md:9:The platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.
specs/06-AUTH-AND-SECURITY.md:62:- When enabled, Laravel sets a long-lived remember token cookie.
specs/06-AUTH-AND-SECURITY.md:63:- The `remember_token` column on the `users` table stores the hashed token.
specs/06-AUTH-AND-SECURITY.md:80:        App->>DB: Store reset token in password_reset_tokens table
specs/06-AUTH-AND-SECURITY.md:84:    B->>App: GET /admin/reset-password/{token}
specs/06-AUTH-AND-SECURITY.md:86:    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)
specs/06-AUTH-AND-SECURITY.md:87:    App->>DB: Validate token (exists, not expired, matches email)
specs/06-AUTH-AND-SECURITY.md:88:    alt Valid token
specs/06-AUTH-AND-SECURITY.md:90:        App->>DB: Delete used token
specs/06-AUTH-AND-SECURITY.md:92:    else Invalid/expired token
specs/06-AUTH-AND-SECURITY.md:100:- Reset tokens stored in the `password_reset_tokens` table.
specs/06-AUTH-AND-SECURITY.md:108:- Steps: invalidate the entire session, regenerate the CSRF token, redirect to `/admin/login`.
specs/06-AUTH-AND-SECURITY.md:197:- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).
specs/06-AUTH-AND-SECURITY.md:199:- Routes: `GET /forgot-password`, `POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.
specs/06-AUTH-AND-SECURITY.md:212:| Token type | Personal access tokens |
specs/06-AUTH-AND-SECURITY.md:216:| Storage | `personal_access_tokens` table |
specs/06-AUTH-AND-SECURITY.md:222:    [*] --> Created: Admin creates token in Developers section
specs/06-AUTH-AND-SECURITY.md:226:    Active --> Revoked: Admin revokes token
specs/06-AUTH-AND-SECURITY.md:235:- Each token has: a human-readable name, an optional expiration date, and a list of abilities (scopes).
specs/06-AUTH-AND-SECURITY.md:236:- On creation, the plain-text token (prefixed with `shop_`) is displayed exactly once. Only the SHA-256 hash is persisted.
specs/06-AUTH-AND-SECURITY.md:240:- Revoking a token deletes its row from the `personal_access_tokens` table.
specs/06-AUTH-AND-SECURITY.md:257:| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |
specs/06-AUTH-AND-SECURITY.md:265:Each API endpoint must check the token's abilities before processing the request. If the token lacks the required ability, the endpoint returns `403 Forbidden`.
specs/06-AUTH-AND-SECURITY.md:279:| `sanctum` | sanctum | `users` | Admin REST API token auth |
specs/06-AUTH-AND-SECURITY.md:292:| `users` | `users` | `password_reset_tokens` | 60 min | 60 sec |
specs/06-AUTH-AND-SECURITY.md:293:| `customers` | `customers` | `customer_password_reset_tokens` | 60 min | 60 sec |
specs/06-AUTH-AND-SECURITY.md:552:| `manage-developers` | Owner, Admin | Create/revoke API tokens, manage webhooks |
specs/06-AUTH-AND-SECURITY.md:614:| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |
specs/06-AUTH-AND-SECURITY.md:616:| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |
specs/06-AUTH-AND-SECURITY.md:719:| Blade forms | Laravel's `@csrf` directive injects a hidden token field (automatic) |
specs/06-AUTH-AND-SECURITY.md:721:| API routes (Sanctum token) | Exempt from CSRF (token-based auth replaces CSRF need) |
specs/06-AUTH-AND-SECURITY.md:728:- API routes (under `api` middleware group) are inherently exempt since they use token auth.
specs/06-AUTH-AND-SECURITY.md:740:| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |
specs/06-AUTH-AND-SECURITY.md:741:| `checkout` | 10 per minute | Session ID | Checkout-related operations |
specs/06-AUTH-AND-SECURITY.md:793:| `ApplyDiscountRequest` | Apply discount to checkout | `code`: required, string, must exist in discounts table for current store. |
specs/06-AUTH-AND-SECURITY.md:876:| Password in API responses | Never returned - `password` and `remember_token` must be in the model's `$hidden` array |
specs/06-AUTH-AND-SECURITY.md:880:- Both `User` and `Customer` models must include `password` and `remember_token` in their `$hidden` array.
specs/06-AUTH-AND-SECURITY.md:1039:| `api_token.created` | New API token issued | token_name, abilities list, user_id |
specs/06-AUTH-AND-SECURITY.md:1040:| `api_token.revoked` | API token revoked | token_name, user_id |
specs/06-AUTH-AND-SECURITY.md:1097:| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |
specs/06-AUTH-AND-SECURITY.md:1100:| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |
specs/05-BUSINESS-LOGIC.md:80:products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_dai…24915 chars truncated…`remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |
specs/02-API-ROUTES.md:1214:All endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.
specs/02-API-ROUTES.md:1226:- **Authentication:** Sanctum token with `manage-platform` scope
specs/02-API-ROUTES.md:1227:- **Rate limit:** `api.admin` (60/min per token)
specs/02-API-ROUTES.md:1267:- **Authentication:** Sanctum token with `manage-platform` scope
specs/02-API-ROUTES.md:1268:- **Rate limit:** `api.admin` (60/min per token)
specs/02-API-ROUTES.md:1320:- **Authentication:** Sanctum token with `manage-platform` scope
specs/02-API-ROUTES.md:1321:- **Rate limit:** `api.admin` (60/min per token)
specs/02-API-ROUTES.md:1361:- **Authentication:** Sanctum token
specs/02-API-ROUTES.md:1362:- **Rate limit:** `api.admin` (60/min per token)
specs/02-API-ROUTES.md:1844:**Authentication:** Bearer token with `write-collections` ability.
specs/02-API-ROUTES.md:2262:**Authentication:** Bearer token with `write-discounts` ability.
specs/02-API-ROUTES.md:2697:**Authentication:** Bearer token with `write-content` ability.
specs/02-API-ROUTES.md:2800:      "add_to_cart_count": 890,
specs/02-API-ROUTES.md:2801:      "checkout_started_count": 420,
specs/02-API-ROUTES.md:2812:        "add_to_cart_count": 72,
specs/02-API-ROUTES.md:2813:        "checkout_started_count": 35
specs/02-API-ROUTES.md:2925:| `state` | string | Yes | CSRF token for the client |
specs/02-API-ROUTES.md:2931:### POST /oauth/token
specs/02-API-ROUTES.md:2933:Exchange an authorization code for access and refresh tokens.
specs/02-API-ROUTES.md:2953:  "token_type": "Bearer",
specs/02-API-ROUTES.md:2955:  "access_token": "eyJ...",
specs/02-API-ROUTES.md:2956:  "refresh_token": "def..."
specs/02-API-ROUTES.md:2964:**Refresh token request:**
specs/02-API-ROUTES.md:2968:  "grant_type": "refresh_token",
specs/02-API-ROUTES.md:2971:  "refresh_token": "def..."
specs/02-API-ROUTES.md:2979:Third-party apps use OAuth tokens to access store data via scoped endpoints.
specs/02-API-ROUTES.md:3042:| `auth:sanctum` | Sanctum token authentication |
specs/02-API-ROUTES.md:3043:| `store.resolve` | Resolve store from route parameter or token context |
specs/02-API-ROUTES.md:3044:| `throttle:api.admin` | Rate limiting (60/min per token) |
specs/02-API-ROUTES.md:3081:| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |
specs/02-API-ROUTES.md:3082:| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |
specs/02-API-ROUTES.md:3083:| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |
specs/02-API-ROUTES.md:3113:| `POST /api/storefront/v1/carts*` | `api.storefront` |
specs/02-API-ROUTES.md:3114:| `GET /api/storefront/v1/carts*` | `api.storefront` |
specs/02-API-ROUTES.md:3115:| `PUT /api/storefront/v1/carts*` | `api.storefront` |
specs/02-API-ROUTES.md:3116:| `DELETE /api/storefront/v1/carts*` | `api.storefront` |
specs/02-API-ROUTES.md:3117:| `POST /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3118:| `GET /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3119:| `PUT /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3120:| `DELETE /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3121:| `POST /api/storefront/v1/checkouts/*/pay` | `checkout` |
specs/02-API-ROUTES.md:3136:The checkout follows a strict state machine with explicit transitions:
specs/02-API-ROUTES.md:3140:    [*] --> started : POST /checkouts (create from cart)
specs/02-API-ROUTES.md:3141:    started --> addressed : PUT /checkouts/{id}/address
specs/02-API-ROUTES.md:3142:    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method
specs/02-API-ROUTES.md:3143:    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method
specs/02-API-ROUTES.md:3144:    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)
specs/02-API-ROUTES.md:3145:    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)
specs/02-API-ROUTES.md:3158:| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |
specs/02-API-ROUTES.md:3159:| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |
specs/02-API-ROUTES.md:3160:| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |
specs/02-API-ROUTES.md:3161:| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |
specs/02-API-ROUTES.md:3165:Applying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.
specs/02-API-ROUTES.md:3216:  "message": "The cart has been modified. Please refresh and try again.",


output:
Chunk ID: d95eb4
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 8729
Output:
specs/06-AUTH-AND-SECURITY.md:9:The platform supports three distinct authentication mechanisms: session-based admin auth, session-based customer auth (store-scoped), and token-based API auth via Sanctum.
specs/06-AUTH-AND-SECURITY.md:62:- When enabled, Laravel sets a long-lived remember token cookie.
specs/06-AUTH-AND-SECURITY.md:63:- The `remember_token` column on the `users` table stores the hashed token.
specs/06-AUTH-AND-SECURITY.md:80:        App->>DB: Store reset token in password_reset_tokens table
specs/06-AUTH-AND-SECURITY.md:84:    B->>App: GET /admin/reset-password/{token}
specs/06-AUTH-AND-SECURITY.md:86:    B->>App: POST /admin/reset-password (token, email, password, password_confirmation)
specs/06-AUTH-AND-SECURITY.md:87:    App->>DB: Validate token (exists, not expired, matches email)
specs/06-AUTH-AND-SECURITY.md:88:    alt Valid token
specs/06-AUTH-AND-SECURITY.md:90:        App->>DB: Delete used token
specs/06-AUTH-AND-SECURITY.md:92:    else Invalid/expired token
specs/06-AUTH-AND-SECURITY.md:100:- Reset tokens stored in the `password_reset_tokens` table.
specs/06-AUTH-AND-SECURITY.md:108:- Steps: invalidate the entire session, regenerate the CSRF token, redirect to `/admin/login`.
specs/06-AUTH-AND-SECURITY.md:197:- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).
specs/06-AUTH-AND-SECURITY.md:199:- Routes: `GET /forgot-password`, `POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.
specs/06-AUTH-AND-SECURITY.md:212:| Token type | Personal access tokens |
specs/06-AUTH-AND-SECURITY.md:216:| Storage | `personal_access_tokens` table |
specs/06-AUTH-AND-SECURITY.md:222:    [*] --> Created: Admin creates token in Developers section
specs/06-AUTH-AND-SECURITY.md:226:    Active --> Revoked: Admin revokes token
specs/06-AUTH-AND-SECURITY.md:235:- Each token has: a human-readable name, an optional expiration date, and a list of abilities (scopes).
specs/06-AUTH-AND-SECURITY.md:236:- On creation, the plain-text token (prefixed with `shop_`) is displayed exactly once. Only the SHA-256 hash is persisted.
specs/06-AUTH-AND-SECURITY.md:240:- Revoking a token deletes its row from the `personal_access_tokens` table.
specs/06-AUTH-AND-SECURITY.md:257:| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |
specs/06-AUTH-AND-SECURITY.md:265:Each API endpoint must check the token's abilities before processing the request. If the token lacks the required ability, the endpoint returns `403 Forbidden`.
specs/06-AUTH-AND-SECURITY.md:279:| `sanctum` | sanctum | `users` | Admin REST API token auth |
specs/06-AUTH-AND-SECURITY.md:292:| `users` | `users` | `password_reset_tokens` | 60 min | 60 sec |
specs/06-AUTH-AND-SECURITY.md:293:| `customers` | `customers` | `customer_password_reset_tokens` | 60 min | 60 sec |
specs/06-AUTH-AND-SECURITY.md:552:| `manage-developers` | Owner, Admin | Create/revoke API tokens, manage webhooks |
specs/06-AUTH-AND-SECURITY.md:614:| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |
specs/06-AUTH-AND-SECURITY.md:616:| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |
specs/06-AUTH-AND-SECURITY.md:719:| Blade forms | Laravel's `@csrf` directive injects a hidden token field (automatic) |
specs/06-AUTH-AND-SECURITY.md:721:| API routes (Sanctum token) | Exempt from CSRF (token-based auth replaces CSRF need) |
specs/06-AUTH-AND-SECURITY.md:728:- API routes (under `api` middleware group) are inherently exempt since they use token auth.
specs/06-AUTH-AND-SECURITY.md:740:| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |
specs/06-AUTH-AND-SECURITY.md:741:| `checkout` | 10 per minute | Session ID | Checkout-related operations |
specs/06-AUTH-AND-SECURITY.md:793:| `ApplyDiscountRequest` | Apply discount to checkout | `code`: required, string, must exist in discounts table for current store. |
specs/06-AUTH-AND-SECURITY.md:876:| Password in API responses | Never returned - `password` and `remember_token` must be in the model's `$hidden` array |
specs/06-AUTH-AND-SECURITY.md:880:- Both `User` and `Customer` models must include `password` and `remember_token` in their `$hidden` array.
specs/06-AUTH-AND-SECURITY.md:1039:| `api_token.created` | New API token issued | token_name, abilities list, user_id |
specs/06-AUTH-AND-SECURITY.md:1040:| `api_token.revoked` | API token revoked | token_name, user_id |
specs/06-AUTH-AND-SECURITY.md:1097:| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |
specs/06-AUTH-AND-SECURITY.md:1100:| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |
specs/05-BUSINESS-LOGIC.md:80:products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
specs/05-BUSINESS-LOGIC.md:154:- Variants represent the cartesian product of option values (or a manually curated subset).
specs/05-BUSINESS-LOGIC.md:162:    desired_combos = cartesianProduct(current_option_values)
specs/05-BUSINESS-LOGIC.md:208:| `quantity_reserved` | Units held for active checkouts or pending orders |
specs/05-BUSINESS-LOGIC.md:217:| `deny` | Block add-to-cart when available <= 0. Storefront displays "Out of stock". |
specs/05-BUSINESS-LOGIC.md:218:| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays "Available on backorder". |
specs/05-BUSINESS-LOGIC.md:265:- Guest users: cart ID stored in session cookie.
specs/05-BUSINESS-LOGIC.md:266:- Authenticated customers: cart linked via `customer_id` foreign key.
specs/05-BUSINESS-LOGIC.md:268:The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.
specs/05-BUSINESS-LOGIC.md:287:**Create cart:**
specs/05-BUSINESS-LOGIC.md:289:- Sets: store_id, currency from store's default_currency, initial cart_version = 1
specs/05-BUSINESS-LOGIC.md:297:5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.
specs/05-BUSINESS-LOGIC.md:300:8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).
specs/05-BUSINESS-LOGIC.md:302:10. Increment `cart_version`.
specs/05-BUSINESS-LOGIC.md:308:- Increment `cart_version`.
specs/05-BUSINESS-LOGIC.md:311:- Delete the `cart_lines` record.
specs/05-BUSINESS-LOGIC.md:312:- Increment `cart_version`.
specs/05-BUSINESS-LOGIC.md:316:Every cart mutation increments `cart_version` by 1.
specs/05-BUSINESS-LOGIC.md:320:- If cart's current version does not match the expected version, return HTTP 409 Conflict with the current cart state in the response body.
specs/05-BUSINESS-LOGIC.md:321:- This prevents race conditions when multiple browser tabs or devices modify the same cart.
specs/05-BUSINESS-LOGIC.md:330:line_discount_amount = 0  (updated by discount engine during checkout)
specs/05-BUSINESS-LOGIC.md:341:- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.
specs/05-BUSINESS-LOGIC.md:343:- Releases any reserved inventory associated with active checkouts for these carts.
specs/05-BUSINESS-LOGIC.md:405:- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.
specs/05-BUSINESS-LOGIC.md:419:    [*] --> started : Checkout created from cart
specs/05-BUSINESS-LOGIC.md:443:**Method:** `setAddress(checkout, addressData)`
specs/05-BUSINESS-LOGIC.md:465:2. Save email and shipping_address_json on the checkout.
specs/05-BUSINESS-LOGIC.md:473:**Method:** `setShippingMethod(checkout, shippingRateId)`
specs/05-BUSINESS-LOGIC.md:476:- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.
specs/05-BUSINESS-LOGIC.md:477:- **Skip condition:** If no cart lines have `requires_shipping = true` on their variant, skip this step. Set status to `shipping_selected` with shipping_method_id = null and shipping_amount = 0.
specs/05-BUSINESS-LOGIC.md:482:3. Save shipping_method_id on the checkout.
specs/05-BUSINESS-LOGIC.md:489:**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method
specs/05-BUSINESS-LOGIC.md:497:1. Store `payment_method` on the checkout record.
specs/05-BUSINESS-LOGIC.md:498:2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).
specs/05-BUSINESS-LOGIC.md:499:3. Set `expires_at` on the checkout to current time + 24 hours.
specs/05-BUSINESS-LOGIC.md:504:**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order
specs/05-BUSINESS-LOGIC.md:508:**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., double-click), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.
specs/05-BUSINESS-LOGIC.md:513:PROCEDURE completeCheckout(checkout, paymentMethodData):
specs/05-BUSINESS-LOGIC.md:516:    1. IF order already exists for this checkout:
specs/05-BUSINESS-LOGIC.md:519:    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)
specs/05-BUSINESS-LOGIC.md:527:       IF checkout.payment_method IN ('credit_card', 'paypal'):
specs/05-BUSINESS-LOGIC.md:532:       ELSE IF checkout.payment_method = 'bank_transfer':
specs/05-BUSINESS-LOGIC.md:539:       - All checkout data (addresses, email, totals snapshot)
specs/05-BUSINESS-LOGIC.md:540:       - payment_method = checkout.payment_method
specs/05-BUSINESS-LOGIC.md:545:    6. FOR EACH cart line:
specs/05-BUSINESS-LOGIC.md:553:       - Link to order, provider = 'mock', method = checkout.payment_method
specs/05-BUSINESS-LOGIC.md:558:       FOR EACH cart line:
specs/05-BUSINESS-LOGIC.md:562:    9. IF checkout has a discount code:
specs/05-BUSINESS-LOGIC.md:565:    10. SET cart status = 'converted'
specs/05-BUSINESS-LOGIC.md:567:    11. SET checkout status = 'completed'
specs/05-BUSINESS-LOGIC.md:593:| `CheckoutAddressed` | Address saved on checkout |
specs/05-BUSINESS-LOGIC.md:608:| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
specs/05-BUSINESS-LOGIC.md:612:- Only one code discount per checkout.
specs/05-BUSINESS-LOGIC.md:619:| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |
specs/05-BUSINESS-LOGIC.md:620:| `active` | Live and applicable to carts (subject to date range and usage limits). | Automatic transition when `starts_at` is reached (if set). Admin can manually activate. |
specs/05-BUSINESS-LOGIC.md:622:| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |
specs/05-BUSINESS-LOGIC.md:657:**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object
specs/05-BUSINESS-LOGIC.md:661:    A[Input: discount code + cart] --> B{Code exists in store?
specs/05-BUSINESS-LOGIC.md:674:    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}
specs/05-BUSINESS-LOGIC.md:688:| `discount_not_applicable` | No qualifying products in cart |
specs/05-BUSINESS-LOGIC.md:695:PROCEDURE allocateDiscount(discount, cartLines):
specs/05-BUSINESS-LOGIC.md:696:    qualifyingLines = FILTER cartLines where product matches applicable_product_ids
specs/05-BUSINESS-LOGIC.md:719:- During checkout: update `cart_lines.line_discount_amount`.
specs/05-BUSINESS-LOGIC.md:801:- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.
specs/05-BUSINESS-LOGIC.md:806:| `"block"` | Block checkout progression if Stripe Tax API errors |
specs/05-BUSINESS-LOGIC.md:807:| `"allow"` | Allow checkout to proceed without tax (log a warning) |
specs/05-BUSINESS-LOGIC.md:858:**When no zone matches:** The address is unserviceable. Block checkout progression with error "Cannot ship to this address".
specs/05-BUSINESS-LOGIC.md:868:- Cost = fixed amount regardless of cart contents.
specs/05-BUSINESS-LOGIC.md:875:PROCEDURE calculateWeightRate(config, cart):
specs/05-BUSINESS-LOGIC.md:877:    FOR EACH line IN cart.lines:
specs/05-BUSINESS-LOGIC.md:893:PROCEDURE calculatePriceRate(config, cartSubtotal):
specs/05-BUSINESS-LOGIC.md:895:        IF range.min_amount <= cartSubtotal:
specs/05-BUSINESS-LOGIC.md:896:            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:
specs/05-BUSINESS-LOGIC.md:913:- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.
specs/05-BUSINESS-LOGIC.md:915:- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).
specs/05-BUSINESS-LOGIC.md:917:**Mixed carts** (some physical, some digital):
specs/05-BUSINESS-LOGIC.md:933:The platform uses a **Mock Payment Service Provider (PSP)** instead of real payment gateways. All payment processing happens in-process with no external API calls, no webhooks, and no redirects. This enables a fully functional checkout flow for development, testing, and demonstration purposes.
specs/05-BUSINESS-LOGIC.md:938:- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)
specs/05-BUSINESS-LOGIC.md:951:| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |
specs/05-BUSINESS-LOGIC.md:952:| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |
specs/05-BUSINESS-LOGIC.md:953:| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |
specs/05-BUSINESS-LOGIC.md:957:When payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:
specs/05-BUSINESS-LOGIC.md:978:    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)
specs/05-BUSINESS-LOGIC.md:980:    Backend->>MockPSP: charge(checkout, paymentMethodData)
specs/05-BUSINESS-LOGIC.md:986:            Backend->>Backend: Commit inventory, mark cart converted
specs/05-BUSINESS-LOGIC.md:1065:- Snapshot all checkout data (addresses, email, totals) at time of order creation.
specs/05-BUSINESS-LOGIC.md:1069:- The `payment_method` from the checkout is copied to the order record.
specs/05-BUSINESS-LOGIC.md:1328:PROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):
specs/05-BUSINESS-LOGIC.md:1334:            - email = checkoutEmail
specs/05-BUSINESS-LOGIC.md:1362:| `checkout.completed` | Checkout successfully completed |
specs/05-BUSINESS-LOGIC.md:1460:| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |
specs/05-BUSINESS-LOGIC.md:1481:| `orders_count` | Count of `checkout_completed` events for the day |
specs/05-BUSINESS-LOGIC.md:1482:| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |
specs/05-BUSINESS-LOGIC.md:1485:| `add_to_cart_count` | Count of `add_to_cart` events |
specs/05-BUSINESS-LOGIC.md:1486:| `checkout_started_count` | Count of `checkout_started` events |
specs/05-BUSINESS-LOGIC.md:1571:    2. Append '*' to last token for prefix matching (autocomplete)
specs/05-BUSINESS-LOGIC.md:1598:| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |
specs/05-BUSINESS-LOGIC.md:1618:| `App\Jobs\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |
specs/05-BUSINESS-LOGIC.md:1619:| `App\Jobs\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |
specs/04-STOREFRONT-UI.md:12:| Interactivity | Livewire v4 for all dynamic UI (cart, filters, checkout) |
specs/04-STOREFRONT-UI.md:52:| Pages | Core storefront pages | home, collection, product, cart, search, page (static CMS) |
specs/04-STOREFRONT-UI.md:53:| Checkout | Purchase flow | checkout index, order confirmation |
specs/04-STOREFRONT-UI.md:60:All storefront pages extend a single master layout, which provides the full page shell including header, footer, cart drawer, and global Livewire listeners. Individual page templates fill the main content section.
specs/04-STOREFRONT-UI.md:146:| Cart Icon | Right group | Button that opens the cart drawer. Displays item count badge (small circular indicator, primary color background, white text) |
specs/04-STOREFRONT-UI.md:212:The cart drawer is always present in the layout as a global Livewire component. It listens for the "cart-updated" browser event to refresh and open. See Section 6 for full specification.
specs/04-STOREFRONT-UI.md:513:| Quick Add | Single variant: "Add to cart" button. Multiple variants: "Choose options" link text. Hidden by default on desktop (shown on hover), always visible on mobile |
specs/04-STOREFRONT-UI.md:673:- On change: updates price, stock messaging, available images, and add-to-cart availability
specs/04-STOREFRONT-UI.md:711:| Text | "Add to cart" |
specs/04-STOREFRONT-UI.md:719:2. Adds to cart (creates cart if none exists in session)
specs/04-STOREFRONT-UI.md:720:3. Dispatches "cart-updated" browser event
specs/04-STOREFRONT-UI.md:739:| Live regions | Price display, stock messaging, add-to-cart feedback |
specs/04-STOREFRONT-UI.md:741:| Keyboard | Tab through thumbnails, variant options, quantity controls, and add-to-cart |
specs/04-STOREFRONT-UI.md:754:| Trigger | "cart-updated" browser event (from add-to-cart actions), or clicking the cart icon in the header |
specs/04-STOREFRONT-UI.md:772:| Close button | X icon, labeled "Close cart" |
specs/04-STOREFRONT-UI.md:797:| Remove button | Trash icon, muted color, turns red on hover. Labeled "Remove {item title} from cart" |
specs/04-STOREFRONT-UI.md:846:| Note | "Shipping and taxes calculated at checkout" in extra-small muted text below totals |
specs/04-STOREFRONT-UI.md:852:| Checkout | Primary, full-width. Creates checkout record, then navigates to checkout page |
specs/04-STOREFRONT-UI.md:853:| Continue shopping | Centered text link below checkout button. Small muted text, hover to darker. Closes the drawer |
specs/04-STOREFRONT-UI.md:861:|  "Your cart is empty"                |
specs/04-STOREFRONT-UI.md:878:| Role | Dialog with modal behavior, labeled "Shopping cart" |
specs/04-STOREFRONT-UI.md:881:| On close | Focus returns to the element that triggered the drawer (cart icon) |
specs/04-STOREFRONT-UI.md:887:## 7. Full Cart Page (GET /cart)
specs/04-STOREFRONT-UI.md:891:Same functionality as the cart drawer but in a full-page layout.
specs/04-STOREFRONT-UI.md:922:| Mobile layout | Card-based layout (same as cart drawer items, stacked) |
specs/04-STOREFRONT-UI.md:930:- "Continue shopping" link: below checkout button
specs/04-STOREFRONT-UI.md:938:## 8. Checkout Page (GET /checkout/{checkoutId})
specs/04-STOREFRONT-UI.md:966:The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment Method & Pay.
specs/04-STOREFRONT-UI.md:1111:| Pay/Place button | Primary, full-width, large text. Loading state: "Processing..." with spinner. Calls `POST /checkouts/{id}/pay` with the selected method and card details (if credit card). |
specs/04-STOREFRONT-UI.md:1143:| Discount code input | Same as cart drawer |
specs/04-STOREFRONT-UI.md:1160:## 9. Order Confirmation (GET /checkout/{checkoutId}/confirmation)
specs/04-STOREFRONT-UI.md:1556:| Swipe gestures | Horizontal swipe on product images (mobile gallery), cart drawer close |
specs/04-STOREFRONT-UI.md:1572:| Screen reader text | Visually hidden text for icon-only buttons (close, cart, search) |
specs/04-STOREFRONT-UI.md:1582:| Cart Drawer | Dialog role with modal behavior, labeled "Shopping cart". Focus trap. Quantity changes in live region |
specs/04-STOREFRONT-UI.md:1670:| compact | boolean | false | Smaller variant for cart drawer |
specs/04-STOREFRONT-UI.md:1683:Renders the checkout order summary sidebar.
specs/04-STOREFRONT-UI.md:1687:| checkout | Checkout model | Required | Checkout with cart, cart lines, and variants eager-loaded |
specs/04-STOREFRONT-UI.md:1730:| ProductPage | Product page | Variant selection, add to cart |
specs/04-STOREFRONT-UI.md:1731:| CartDrawer | Layout (global) | Slide-in cart with line management |
specs/04-STOREFRONT-UI.md:1732:| CartPage | Cart page | Full-page cart |
specs/04-STOREFRONT-UI.md:1733:| CheckoutPage | Checkout | Multi-step checkout flow |
specs/04-STOREFRONT-UI.md:1748:| cart-updated | ProductPage, CartDrawer, CartPage | CartDrawer (opens + refreshes), Header (badge count) | cartId, itemCount |
specs/04-STOREFRONT-UI.md:1749:| open-cart-drawer | Header cart icon click | CartDrawer | None |
specs/04-STOREFRONT-UI.md:1750:| close-cart-drawer | CartDrawer close actions | CartDrawer | None |
specs/04-STOREFRONT-UI.md:1883:        HasItems --> Checkout: Proceed to checkout
specs/04-STOREFRONT-UI.md:1890:        InStock --> Adding: Click add to cart
specs/04-STOREFRONT-UI.md:1912:| Item added to cart | "Added to cart" | success |
specs/04-STOREFRONT-UI.md:1913:| Item removed from cart | "Item removed" | success |
specs/04-STOREFRONT-UI.md:1914:| Cart is empty | "Your cart is empty" | info |
specs/02-API-ROUTES.md:3:This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.
specs/02-API-ROUTES.md:22:| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\Auth\ResetPassword` |
specs/02-API-ROUTES.md:23:| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |
specs/02-API-ROUTES.md:128:| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |
specs/02-API-ROUTES.md:157:| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\Products\Show` |
specs/02-API-ROUTES.md:158:| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\Cart\Show` |
specs/02-API-ROUTES.md:181:| GET | `/reset-password/{token}` | Reset password form | `Storefront\Account\Auth\ResetPassword` |
specs/02-API-ROUTES.md:192:| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\Checkout\Show` |
specs/02-API-ROUTES.md:193:| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\Checkout\Confirmation` |
specs/02-API-ROUTES.md:203:These endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).
specs/02-API-ROUTES.md:209:#### POST /api/storefront/v1/carts
specs/02-API-ROUTES.md:211:Create a new cart.
specs/02-API-ROUTES.md:236:  "cart_version": 1,
specs/02-API-ROUTES.md:255:#### GET /api/storefront/v1/carts/{cartId}
specs/02-API-ROUTES.md:257:Retrieve a cart with all lines and computed totals.
specs/02-API-ROUTES.md:259:- **Authentication:** None (cart ID acts as token)
specs/02-API-ROUTES.md:266:| `cartId` | integer | Yes | The cart identifier |
specs/02-API-ROUTES.md:276:  "cart_version": 3,
specs/02-API-ROUTES.md:313:#### POST /api/storefront/v1/carts/{cartId}/lines
specs/02-API-ROUTES.md:315:Add a line item to the cart.
specs/02-API-ROUTES.md:336:Returns the full cart object (same schema as GET /carts/{cartId}).
specs/02-API-ROUTES.md:340:- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)
specs/02-API-ROUTES.md:354:#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}
specs/02-API-ROUTES.md:356:Update the quantity of a cart line.
specs/02-API-ROUTES.md:366:  "cart_version": 3
specs/02-API-ROUTES.md:373:| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |
specs/02-API-ROUTES.md:377:Returns the full cart object with updated totals.
specs/02-API-ROUTES.md:386:#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}
specs/02-API-ROUTES.md:388:Remove a line item from the cart.
specs/02-API-ROUTES.md:397:  "cart_version": 3
specs/02-API-ROUTES.md:403:| `cart_version` | integer | Yes | Must match current cart version. |
specs/02-API-ROUTES.md:407:Returns the full cart object.
specs/02-API-ROUTES.md:425:    C->>API: POST /checkouts (cart_id, email)
specs/02-API-ROUTES.md:428:    C->>API: PUT /checkouts/{id}/address
specs/02-API-ROUTES.md:431:    C->>API: PUT /checkouts/{id}/shipping-method
specs/02-API-ROUTES.md:435:        C->>API: POST /checkouts/{id}/apply-discount
specs/02-API-ROUTES.md:439:    C->>API: POST /checkouts/{id}/pay (payment_method + details)
specs/02-API-ROUTES.md:441:    API->>MockPSP: charge(checkout, paymentMethodData)
specs/02-API-ROUTES.md:458:#### POST /api/storefront/v1/checkouts
specs/02-API-ROUTES.md:460:Create a checkout from a cart.
specs/02-API-ROUTES.md:463:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:469:  "cart_id": 42,
specs/02-API-ROUTES.md:476:| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |
specs/02-API-ROUTES.md:485:  "cart_id": 42,
specs/02-API-ROUTES.md:520:- `422` - Validation errors (empty cart, invalid email)
specs/02-API-ROUTES.md:524:#### GET /api/storefront/v1/checkouts/{checkoutId}
specs/02-API-ROUTES.md:526:Retrieve the current checkout state and computed totals.
specs/02-API-ROUTES.md:528:- **Authentication:** None (checkout ID acts as token)
specs/02-API-ROUTES.md:529:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:533:Returns the full checkout object (same schema as POST response, with updated fields reflecting the current state).
specs/02-API-ROUTES.md:541:#### PUT /api/storefront/v1/checkouts/{checkoutId}/address
specs/02-API-ROUTES.md:543:Set the shipping and/or billing address on a checkout.
specs/02-API-ROUTES.md:546:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:591:Returns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.
specs/02-API-ROUTES.md:661:#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method
specs/02-API-ROUTES.md:663:Select a shipping method for the checkout.
specs/02-API-ROUTES.md:666:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:678:| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |
specs/02-API-ROUTES.md:682:Returns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).
specs/02-API-ROUTES.md:717:- `422` - Invalid shipping method (not available for this address/checkout)
specs/02-API-ROUTES.md:721:#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method
specs/02-API-ROUTES.md:723:Select a payment method for the checkout. This step records the chosen method without processing payment.
specs/02-API-ROUTES.md:726:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:742:Returns the full checkout object. Status transitions to `payment_selected`.
specs/02-API-ROUTES.md:763:- `422` - Invalid payment method or checkout not in `shipping_selected` state
specs/02-API-ROUTES.md:767:#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount
specs/02-API-ROUTES.md:769:Apply a discount code to the checkout.
specs/02-API-ROUTES.md:772:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:829:#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount
specs/02-API-ROUTES.md:831:Remove the applied discount code from the checkout.
specs/02-API-ROUTES.md:834:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:838:Returns the full checkout object with discount removed and totals recalculated.
specs/02-API-ROUTES.md:848:#### POST /api/storefront/v1/checkouts/{checkoutId}/pay
specs/02-API-ROUTES.md:850:Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.
specs/02-API-ROUTES.md:853:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:895:  "checkout_id": 7,
specs/02-API-ROUTES.md:913:  "checkout_id": 7,
specs/02-API-ROUTES.md:960:Retrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).
specs/02-API-ROUTES.md:962:- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)
specs/02-API-ROUTES.md:969:| `token` | string | Yes | HMAC-signed access token for this order |
specs/02-API-ROUTES.md:1012:- `401` - Invalid or missing token
specs/02-API-ROUTES.md:1167:      "type": "add_to_cart",
specs/02-API-ROUTES.md:1186:| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |
specs/02-API-ROUTES.md:1214:All endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.
specs/02-API-ROUTES.md:1226:- **Authentication:** Sanctum token with `manage-platform` scope
specs/02-API-ROUTES.md:1227:- **Rate limit:** `api.admin` (60/min per token)
specs/02-API-ROUTES.md:1267:- **Authentication:** Sanctum token with `manage-platform` scope
specs/02-API-ROUTES.md:1268:- **Rate limit:** `api.admin` (60/min per token)
specs/02-API-ROUTES.md:1320:- **Authentication:** Sanctum token with `manage-platform` scope
specs/02-API-ROUTES.md:1321:- **Rate limit:** `api.admin` (60/min per token)
specs/02-API-ROUTES.md:1361:- **Authentication:** Sanctum token
specs/02-API-ROUTES.md:1362:- **Rate limit:** `api.admin` (60/min per token)
specs/02-API-ROUTES.md:1844:**Authentication:** Bearer token with `write-collections` ability.
specs/02-API-ROUTES.md:2262:**Authentication:** Bearer token with `write-discounts` ability.
specs/02-API-ROUTES.md:2697:**Authentication:** Bearer token with `write-content` ability.
specs/02-API-ROUTES.md:2800:      "add_to_cart_count": 890,
specs/02-API-ROUTES.md:2801:      "checkout_started_count": 420,
specs/02-API-ROUTES.md:2812:        "add_to_cart_count": 72,
specs/02-API-ROUTES.md:2813:        "checkout_started_count": 35
specs/02-API-ROUTES.md:2925:| `state` | string | Yes | CSRF token for the client |
specs/02-API-ROUTES.md:2931:### POST /oauth/token
specs/02-API-ROUTES.md:2933:Exchange an authorization code for access and refresh tokens.
specs/02-API-ROUTES.md:2953:  "token_type": "Bearer",
specs/02-API-ROUTES.md:2955:  "access_token": "eyJ...",
specs/02-API-ROUTES.md:2956:  "refresh_token": "def..."
specs/02-API-ROUTES.md:2964:**Refresh token request:**
specs/02-API-ROUTES.md:2968:  "grant_type": "refresh_token",
specs/02-API-ROUTES.md:2971:  "refresh_token": "def..."
specs/02-API-ROUTES.md:2979:Third-party apps use OAuth tokens to access store data via scoped endpoints.
specs/02-API-ROUTES.md:3042:| `auth:sanctum` | Sanctum token authentication |
specs/02-API-ROUTES.md:3043:| `store.resolve` | Resolve store from route parameter or token context |
specs/02-API-ROUTES.md:3044:| `throttle:api.admin` | Rate limiting (60/min per token) |
specs/02-API-ROUTES.md:3081:| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |
specs/02-API-ROUTES.md:3082:| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |
specs/02-API-ROUTES.md:3083:| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |
specs/02-API-ROUTES.md:3113:| `POST /api/storefront/v1/carts*` | `api.storefront` |
specs/02-API-ROUTES.md:3114:| `GET /api/storefront/v1/carts*` | `api.storefront` |
specs/02-API-ROUTES.md:3115:| `PUT /api/storefront/v1/carts*` | `api.storefront` |
specs/02-API-ROUTES.md:3116:| `DELETE /api/storefront/v1/carts*` | `api.storefront` |
specs/02-API-ROUTES.md:3117:| `POST /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3118:| `GET /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3119:| `PUT /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3120:| `DELETE /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3121:| `POST /api/storefront/v1/checkouts/*/pay` | `checkout` |
specs/02-API-ROUTES.md:3136:The checkout follows a strict state machine with explicit transitions:
specs/02-API-ROUTES.md:3140:    [*] --> started : POST /checkouts (create from cart)
specs/02-API-ROUTES.md:3141:    started --> addressed : PUT /checkouts/{id}/address
specs/02-API-ROUTES.md:3142:    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method
specs/02-API-ROUTES.md:3143:    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method
specs/02-API-ROUTES.md:3144:    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)
specs/02-API-ROUTES.md:3145:    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)
specs/02-API-ROUTES.md:3158:| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |
specs/02-API-ROUTES.md:3159:| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |
specs/02-API-ROUTES.md:3160:| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |
specs/02-API-ROUTES.md:3161:| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |
specs/02-API-ROUTES.md:3165:Applying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.
specs/02-API-ROUTES.md:3216:  "message": "The cart has been modified. Please refresh and try again.",


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

namespace App\Http\Resources\Storefront\V1;

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

class CheckoutResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'cart_id' => $this->cart_id,
            'customer_id' => $this->customer_id,
            'status' => $this->status?->value,
            'payment_method' => $this->payment_method,
            'email' => $this->email,
            'shipping_address' => $this->shipping_address_json,
            'billing_address' => $this->billing_address_json,
            'shipping_method_id' => $this->shipping_method_id,
            'discount_code' => $this->discount_code,
            'totals' => $this->totals_json,
            'tax_provider_snapshot' => $this->tax_provider_snapshot_json,
            'available_shipping_rates' => ShippingRateResource::collection($this->whenLoaded('availableRates')),
            'cart' => new CartResource($this->whenLoaded('cart')),
            'expires_at' => $this->expires_at?->toIso8601String(),
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}
<?php

namespace App\Http\Resources\Storefront\V1;

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

class CartResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        $lines = $this->whenLoaded('lines');

        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'customer_id' => $this->customer_id,
            'currency' => $this->currency,
            'status' => $this->status?->value,
            'cart_version' => $this->cart_version,
            'line_count' => $this->relationLoaded('lines') ? $this->lines->sum('quantity') : null,
            'totals' => $this->relationLoaded('lines') ? [
                'subtotal' => $this->lines->sum('line_subtotal_amount'),
                'discount' => $this->lines->sum('line_discount_amount'),
                'total' => $this->lines->sum('line_total_amount'),
                'currency' => $this->currency,
            ] : null,
            'lines' => CartLineResource::collection($lines),
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }
}


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

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('carts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
            $table->string('currency', 3)->default('USD');
            $table->unsignedInteger('cart_version')->default(1);
            $table->enum('status', ['active', 'converted', 'abandoned'])->default('active');
            $table->timestamps();

            $table->index('store_id');
            $table->index('customer_id');
            $table->index(['store_id', 'status']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('carts');
    }
};
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('checkouts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
            $table->enum('status', ['started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired'])->default('started');
            $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer'])->nullable();
            $table->string('email')->nullable();
            $table->text('shipping_address_json')->nullable();
            $table->text('billing_address_json')->nullable();
            $table->unsignedBigInteger('shipping_method_id')->nullable();
            $table->string('discount_code')->nullable();
            $table->text('tax_provider_snapshot_json')->nullable();
            $table->text('totals_json')->nullable();
            $table->timestamp('expires_at')->nullable();
            $table->timestamps();

            $table->index('store_id');
            $table->index('cart_id');
            $table->index('customer_id');
            $table->index(['store_id', 'status']);
            $table->index('expires_at');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('checkouts');
    }
};


cmd: rg -n "class OrderAccessToken|OrderAccessToken" app tests && sed -n '1,160p' app/Support/OrderAccessToken.php


command: rg -n "class OrderAccessToken|OrderAccessToken" app tests && sed -n '1,160p' app/Support/OrderAccessToken.php

type: search

cmd: rg -n 'class OrderAccessToken|OrderAccessToken' app tests

query: class OrderAccessToken|OrderAccessToken

path: app

OrderAccessToken.php

aggregated_output:
app/Support/OrderAccessToken.php:9:class OrderAccessToken
app/Http/Controllers/Api/Storefront/V1/OrderController.php:9:use App\Support\OrderAccessToken;
app/Http/Controllers/Api/Storefront/V1/OrderController.php:26:        abort_unless(OrderAccessToken::valid($order, $request->query('token')), 404);
app/Http/Resources/Storefront/V1/OrderResource.php:6:use App\Support\OrderAccessToken;
app/Http/Resources/Storefront/V1/OrderResource.php:22:            'access_token' => OrderAccessToken::make($this->resource),
<?php

namespace App\Support;

use App\Models\Order;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;

class OrderAccessToken
{
    public static function make(Order $order): string
    {
        return hash_hmac('sha256', self::payload($order), self::key());
    }

    public static function valid(Order $order, ?string $token): bool
    {
        if ($token === null || $token === '') {
            return false;
        }

        return hash_equals(self::make($order), $token);
    }

    private static function payload(Order $order): string
    {
        return implode('|', [
            $order->store_id,
            $order->getKey(),
            $order->order_number,
            $order->created_at?->timestamp ?? 0,
        ]);
    }

    private static function key(): string
    {
        $key = (string) Config::get('app.key');

        return Str::startsWith($key, 'base64:')
            ? base64_decode(Str::after($key, 'base64:'), true) ?: $key
            : $key;
    }
}


output:
Chunk ID: 66531a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 381
Output:
app/Support/OrderAccessToken.php:9:class OrderAccessToken
app/Http/Controllers/Api/Storefront/V1/OrderController.php:9:use App\Support\OrderAccessToken;
app/Http/Controllers/Api/Storefront/V1/OrderController.php:26:        abort_unless(OrderAccessToken::valid($order, $request->query('token')), 404);
app/Http/Resources/Storefront/V1/OrderResource.php:6:use App\Support\OrderAccessToken;
app/Http/Resources/Storefront/V1/OrderResource.php:22:            'access_token' => OrderAccessToken::make($this->resource),
<?php

namespace App\Support;

use App\Models\Order;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;

class OrderAccessToken
{
    public static function make(Order $order): string
    {
        return hash_hmac('sha256', self::payload($order), self::key());
    }

    public static function valid(Order $order, ?string $token): bool
    {
        if ($token === null || $token === '') {
            return false;
        }

        return hash_equals(self::make($order), $token);
    }

    private static function payload(Order $order): string
    {
        return implode('|', [
            $order->store_id,
            $order->getKey(),
            $order->order_number,
            $order->created_at?->timestamp ?? 0,
        ]);
    }

    private static function key(): string
    {
        $key = (string) Config::get('app.key');

        return Str::startsWith($key, 'base64:')
            ? base64_decode(Str::after($key, 'base64:'), true) ?: $key
            : $key;
    }
}


cmd: sed -n '1,180p' tests/Feature/Api/StorefrontOrderApiTest.php


cmd: sed -n '1,140p' app/Http/Requests/Api/Storefront/V1/CompleteCheckoutPaymentRequest.php && sed -n '1,100p' app/Http/Requests/Api/Storefront/V1/SetCheckoutAddressRequest.php


cmd: rg -n "api/storefront/v1/checkouts" tests/Feature tests/Browser


command: sed -n '1,180p' tests/Feature/Api/StorefrontOrderApiTest.php

aggregated_output:
<?php

use App\Enums\CheckoutStatus;
use App\Models\Checkout;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

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

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

function storefrontOrderApiVariant(Store $store): ProductVariant
{
    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'classic-cotton-t-shirt')
        ->firstOrFail();

    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->oldest('position')
        ->firstOrFail();

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

    return $variant->refresh();
}

/**
 * @return array{0: int, 1: ProductVariant}
 */
function storefrontOrderApiCheckout(object $testCase, string $remoteAddress = '10.0.0.41'): array
{
    $store = storefrontOrderApiStore();
    $variant = storefrontOrderApiVariant($store);
    $api = fn () => $testCase->withServerVariables(['REMOTE_ADDR' => $remoteAddress])->withHeader('Host', 'shop.test');

    $cartId = $api()
        ->postJson('/api/storefront/v1/carts')
        ->assertCreated()['data']['id'];

    $api()
        ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [
            'variant_id' => $variant->getKey(),
            'quantity' => 1,
        ])
        ->assertCreated();

    $checkoutId = $api()
        ->postJson('/api/storefront/v1/checkouts', [
            'cart_id' => $cartId,
            'email' => 'buyer@example.test',
        ])
        ->assertCreated()['data']['id'];

    $addressResponse = $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
            'shipping_address' => [
                'first_name' => 'Test',
                'last_name' => 'Buyer',
                'address1' => 'Main Street 1',
                'city' => 'Berlin',
                'country' => 'DE',
                'postal_code' => '10115',
            ],
        ])
        ->assertOk();

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
            'shipping_rate_id' => $addressResponse['data']['available_shipping_rates'][0]['id'],
        ])
        ->assertOk();

    return [$checkoutId, $variant];
}

test('storefront order api pays a checkout and exposes token-gated order lookup', function (): void {
    [$checkoutId] = storefrontOrderApiCheckout($this);
    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.41'])->withHeader('Host', 'shop.test');

    $payResponse = $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [
            'payment_method' => 'credit_card',
            'card_number' => '4242 4242 4242 4242',
            'card_holder' => 'Test Buyer',
            'card_expiry' => '12/30',
            'card_cvc' => '123',
        ]);

    $payResponse
        ->assertOk()
        ->assertJsonPath('data.order_number', '#1016')
        ->assertJsonPath('data.financial_status', 'paid')
        ->assertJsonCount(1, 'data.lines');

    $token = $payResponse['data']['access_token'];
    $orderNumber = $payResponse['data']['order_number'];

    $api()
        ->getJson('/api/storefront/v1/orders/'.rawurlencode($orderNumber).'?token='.$token)
        ->assertOk()
        ->assertJsonPath('data.order_number', $orderNumber)
        ->assertJsonPath('data.total_amount', $payResponse['data']['total_amount']);

    $api()
        ->getJson('/api/storefront/v1/orders/'.rawurlencode($orderNumber).'?token=bad-token')
        ->assertNotFound();
});

test('storefront order api returns the same order when checkout payment is retried', function (): void {
    [$checkoutId, $variant] = storefrontOrderApiCheckout($this, '10.0.0.43');
    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.43'])->withHeader('Host', 'shop.test');
    $payload = [
        'payment_method' => 'credit_card',
        'card_number' => '4242 4242 4242 4242',
        'card_holder' => 'Test Buyer',
        'card_expiry' => '12/30',
        'card_cvc' => '123',
    ];

    $firstResponse = $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", $payload)
        ->assertOk();
    $secondResponse = $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", $payload)
        ->assertOk();

    $orderId = $firstResponse->json('data.id');

    expect($secondResponse->json('data.id'))->toBe($orderId)
        ->and(Order::withoutGlobalScopes()->where('checkout_id', $checkoutId)->count())->toBe(1)
        ->and(Payment::query()->where('order_id', $orderId)->count())->toBe(1)
        ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved)->toBe(0);
});

test('storefront order api returns payment failures and releases reservations', function (): void {
    [$checkoutId, $variant] = storefrontOrderApiCheckout($this, '10.0.0.42');
    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.42'])->withHeader('Host', 'shop.test');

    $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [
            'payment_method' => 'credit_card',
            'card_number' => '4000 0000 0000 0002',
            'card_holder' => 'Test Buyer',
            'card_expiry' => '12/30',
            'card_cvc' => '123',
        ])
        ->assertStatus(402);

    $checkout = Checkout::withoutGlobalScopes()->findOrFail($checkoutId);
    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();

    expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected)
        ->and($inventory->quantity_reserved)->toBe(0);
});


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

use App\Enums\CheckoutStatus;
use App\Models\Checkout;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

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

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

function storefrontOrderApiVariant(Store $store): ProductVariant
{
    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('handle', 'classic-cotton-t-shirt')
        ->firstOrFail();

    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->oldest('position')
        ->firstOrFail();

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

    return $variant->refresh();
}

/**
 * @return array{0: int, 1: ProductVariant}
 */
function storefrontOrderApiCheckout(object $testCase, string $remoteAddress = '10.0.0.41'): array
{
    $store = storefrontOrderApiStore();
    $variant = storefrontOrderApiVariant($store);
    $api = fn () => $testCase->withServerVariables(['REMOTE_ADDR' => $remoteAddress])->withHeader('Host', 'shop.test');

    $cartId = $api()
        ->postJson('/api/storefront/v1/carts')
        ->assertCreated()['data']['id'];

    $api()
        ->postJson("/api/storefront/v1/carts/{$cartId}/lines", [
            'variant_id' => $variant->getKey(),
            'quantity' => 1,
        ])
        ->assertCreated();

    $checkoutId = $api()
        ->postJson('/api/storefront/v1/checkouts', [
            'cart_id' => $cartId,
            'email' => 'buyer@example.test',
        ])
        ->assertCreated()['data']['id'];

    $addressResponse = $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/address", [
            'shipping_address' => [
                'first_name' => 'Test',
                'last_name' => 'Buyer',
                'address1' => 'Main Street 1',
                'city' => 'Berlin',
                'country' => 'DE',
                'postal_code' => '10115',
            ],
        ])
        ->assertOk();

    $api()
        ->putJson("/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [
            'shipping_rate_id' => $addressResponse['data']['available_shipping_rates'][0]['id'],
        ])
        ->assertOk();

    return [$checkoutId, $variant];
}

test('storefront order api pays a checkout and exposes token-gated order lookup', function (): void {
    [$checkoutId] = storefrontOrderApiCheckout($this);
    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.41'])->withHeader('Host', 'shop.test');

    $payResponse = $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [
            'payment_method' => 'credit_card',
            'card_number' => '4242 4242 4242 4242',
            'card_holder' => 'Test Buyer',
            'card_expiry' => '12/30',
            'card_cvc' => '123',
        ]);

    $payResponse
        ->assertOk()
        ->assertJsonPath('data.order_number', '#1016')
        ->assertJsonPath('data.financial_status', 'paid')
        ->assertJsonCount(1, 'data.lines');

    $token = $payResponse['data']['access_token'];
    $orderNumber = $payResponse['data']['order_number'];

    $api()
        ->getJson('/api/storefront/v1/orders/'.rawurlencode($orderNumber).'?token='.$token)
        ->assertOk()
        ->assertJsonPath('data.order_number', $orderNumber)
        ->assertJsonPath('data.total_amount', $payResponse['data']['total_amount']);

    $api()
        ->getJson('/api/storefront/v1/orders/'.rawurlencode($orderNumber).'?token=bad-token')
        ->assertNotFound();
});

test('storefront order api returns the same order when checkout payment is retried', function (): void {
    [$checkoutId, $variant] = storefrontOrderApiCheckout($this, '10.0.0.43');
    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.43'])->withHeader('Host', 'shop.test');
    $payload = [
        'payment_method' => 'credit_card',
        'card_number' => '4242 4242 4242 4242',
        'card_holder' => 'Test Buyer',
        'card_expiry' => '12/30',
        'card_cvc' => '123',
    ];

    $firstResponse = $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", $payload)
        ->assertOk();
    $secondResponse = $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", $payload)
        ->assertOk();

    $orderId = $firstResponse->json('data.id');

    expect($secondResponse->json('data.id'))->toBe($orderId)
        ->and(Order::withoutGlobalScopes()->where('checkout_id', $checkoutId)->count())->toBe(1)
        ->and(Payment::query()->where('order_id', $orderId)->count())->toBe(1)
        ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved)->toBe(0);
});

test('storefront order api returns payment failures and releases reservations', function (): void {
    [$checkoutId, $variant] = storefrontOrderApiCheckout($this, '10.0.0.42');
    $api = fn () => $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.42'])->withHeader('Host', 'shop.test');

    $api()
        ->postJson("/api/storefront/v1/checkouts/{$checkoutId}/pay", [
            'payment_method' => 'credit_card',
            'card_number' => '4000 0000 0000 0002',
            'card_holder' => 'Test Buyer',
            'card_expiry' => '12/30',
            'card_cvc' => '123',
        ])
        ->assertStatus(402);

    $checkout = Checkout::withoutGlobalScopes()->findOrFail($checkoutId);
    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();

    expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected)
        ->and($inventory->quantity_reserved)->toBe(0);
});


command: sed -n '1,140p' app/Http/Requests/Api/Storefront/V1/CompleteCheckoutPaymentRequest.php && sed -n '1,100p' app/Http/Requests/Api/Storefront/V1/SetCheckoutAddressRequest.php

SetCheckoutAddressRequest.php

aggregated_output:
<?php

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

use Illuminate\Foundation\Http\FormRequest;

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

    /**
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'],
            'card_number' => ['required_if:payment_method,credit_card', 'string'],
            'card_holder' => ['required_if:payment_method,credit_card', 'string', 'max:255'],
            'card_expiry' => ['required_if:payment_method,credit_card', 'string', 'max:20'],
            'card_cvc' => ['required_if:payment_method,credit_card', 'string', 'max:10'],
        ];
    }
}
<?php

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

use Illuminate\Foundation\Http\FormRequest;

class SetCheckoutAddressRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'email' => ['nullable', 'email'],
            'shipping_address' => ['required', 'array'],
            'shipping_address.first_name' => ['required', 'string'],
            'shipping_address.last_name' => ['required', 'string'],
            'shipping_address.address1' => ['required', 'string'],
            'shipping_address.address2' => ['nullable', 'string'],
            'shipping_address.city' => ['required', 'string'],
            'shipping_address.province_code' => ['nullable', 'string'],
            'shipping_address.country' => ['required', 'string', 'size:2'],
            'shipping_address.country_code' => ['nullable', 'string', 'size:2'],
            'shipping_address.postal_code' => ['required', 'string'],
            'billing_address' => ['nullable', 'array'],
        ];
    }
}