text:
Script completed
Wall time 0.1 seconds
Output:

text:
 M app/Http/Controllers/Api/AdminController.php
 M app/Http/Controllers/Api/PlatformController.php
 M app/Http/Controllers/Api/StorefrontAnalyticsController.php
 M app/Http/Controllers/Api/StorefrontCheckoutController.php
 M app/Http/Controllers/Api/StorefrontOrderController.php
 M app/Http/Controllers/Api/StorefrontSearchController.php
 M app/Http/Middleware/EnsureApiAbility.php
 M app/Http/Middleware/ResolveStore.php
 M app/Http/Requests/CreateFulfillmentRequest.php
 M app/Http/Requests/CreateRefundRequest.php
 M app/Http/Requests/StoreInvitationRequest.php
 M app/Http/Requests/StoreShippingRateRequest.php
 M app/Http/Requests/StoreShippingZoneRequest.php
 M app/Http/Requests/StoreThemeRequest.php
 M app/Http/Requests/UpdateTaxSettingsRequest.php
 M app/Http/Requests/UpdateThemeSettingsRequest.php
 M app/Jobs/AggregateAnalytics.php
 M app/Jobs/CancelUnpaidBankTransferOrders.php
 M app/Jobs/DeliverWebhook.php
 M app/Jobs/ExpireAbandonedCheckouts.php
 M app/Jobs/ProcessMediaUpload.php
 M app/Livewire/Admin/Developers/Index.php
 M app/Livewire/Storefront/CartDrawer.php
 M app/Livewire/Storefront/Collections/Show.php
 M app/Livewire/Storefront/Products/Show.php
 M app/Livewire/Storefront/Search/Modal.php
 M app/Models/Payment.php
 M app/Models/ThemeFile.php
 M app/Models/User.php
 M app/Observers/ProductObserver.php
 M app/Providers/AppServiceProvider.php
 M app/Services/CheckoutService.php
 M app/Services/FulfillmentService.php
 M app/Services/MockPaymentProvider.php
 M app/Services/OrderService.php
 M app/Services/PaymentService.php
 M app/Services/PricingEngine.php
 M app/Services/SearchService.php
 M app/Services/ShippingCalculator.php
 M app/Services/TaxCalculator.php
 M app/ValueObjects/PaymentResult.php
 M boost.json
 M bootstrap/app.php
 M config/cors.php
 M config/fortify.php
 M config/session.php
 M database/factories/PaymentFactory.php
 M database/factories/UserFactory.php
 M database/seeders/AnalyticsSeeder.php
 M database/seeders/NavigationSeeder.php
 M database/seeders/PageSeeder.php
 M database/seeders/SearchSettingsSeeder.php
 M database/seeders/UserSeeder.php
 M resources/views/layouts/admin.blade.php
 M resources/views/layouts/storefront.blade.php
 M resources/views/livewire/storefront/cart-drawer.blade.php
 M resources/views/livewire/storefront/collections/show.blade.php
 M resources/views/livewire/storefront/home.blade.php
 M resources/views/livewire/storefront/products/show.blade.php
 M routes/api.php
 M tests/Feature/ApiTokenTest.php
 M tests/Feature/CommerceFlowTest.php
 M tests/Feature/ContractBehaviorTest.php
 M tests/Feature/PlatformApiAndMediaTest.php
 M tests/Feature/SearchAnalyticsWebhookTest.php
?? .playwright-mcp/
?? app/Events/CheckoutAddressed.php
?? app/Events/CheckoutCompleted.php
?? app/Events/CheckoutExpired.php
?? app/Events/CheckoutShippingSelected.php
?? app/Events/FulfillmentCreated.php
?? app/Events/ProductCreated.php
?? app/Events/ProductDeleted.php
?? app/Events/ProductUpdated.php
?? app/Exceptions/PaymentDeclinedException.php
?? app/Http/Requests/CreateOrderExportRequest.php
?? app/Http/Requests/StoreAnalyticsEventsRequest.php
?? app/Jobs/GenerateOrderExport.php
?? app/Listeners/
?? app/Models/OrderExport.php
?? database/factories/OrderExportFactory.php
?? database/migrations/2026_08_21_002127_create_order_exports_table.php
?? database/migrations/2026_08_21_003147_add_platform_admin_and_payment_raw_json_columns.php
?? tests/Feature/OrderExportsAndDomainEventsTest.php
<?php

namespace App\Http\Controllers\Api;

use App\Exceptions\CartVersionConflictException;
use App\Exceptions\InsufficientInventoryException;
use App\Http\Controllers\Controller;
use App\Models\Cart;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StorefrontCartController extends Controller
{
    public function __construct(private readonly CartService $carts) {}

    public function store(Request $request): JsonResponse
    {
        $data = $request->validate(['currency' => ['nullable', 'string', 'size:3']]);
        $cart = $this->carts->create(app('current_store'), $request->user('customer'));

        if (isset($data['currency'])) {
            $cart->update(['currency' => strtoupper($data['currency'])]);
        }

        $request->session()->put(['cart_id' => $cart->getKey(), 'cart_id_'.app('current_store')->getKey() => $cart->getKey()]);

        return response()->json($this->payload($cart->refresh()), 201);
    }

    public function show(int $cartId): JsonResponse
    {
        return response()->json($this->payload($this->cart($cartId)));
    }

    public function addLine(Request $request, int $cartId): JsonResponse
    {
        $data = $request->validate(['variant_id' => ['required', 'integer'], 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], 'cart_version' => ['nullable', 'integer'], 'expected_version' => ['nullable', 'integer']]);
        $cart = $this->cart($cartId);

        try {
            $this->carts->assertVersion($cart, $data['cart_version'] ?? $data['expected_version'] ?? null);
            $this->carts->addLine($cart, $data['variant_id'], $data['quantity']);
        } catch (CartVersionConflictException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'cart' => $this->payload($cart->refresh())], 409);
        } catch (InsufficientInventoryException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422);
        }

        return response()->json($this->payload($this->cart($cartId)), 201);
    }

    public function updateLine(Request $request, int $cartId, int $lineId): JsonResponse
    {
        $data = $request->validate(['quantity' => ['required', 'integer', 'min:1', 'max:9999'], 'cart_version' => ['required', 'integer']]);
        $cart = $this->cart($cartId);

        try {
            $this->carts->assertVersion($cart, $data['cart_version']);
            $this->carts->updateLineQuantity($cart, $lineId, $data['quantity']);
        } catch (CartVersionConflictException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'cart' => $this->payload($cart->refresh())], 409);
        } catch (InsufficientInventoryException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422);
        }

        return response()->json($this->payload($this->cart($cartId)));
    }

    public function removeLine(Request $request, int $cartId, int $lineId): JsonResponse
    {
        $data = $request->validate(['cart_version' => ['required', 'integer']]);
        $cart = $this->cart($cartId);

        try {
            $this->carts->assertVersion($cart, $data['cart_version']);
            $this->carts->removeLine($cart, $lineId);
        } catch (CartVersionConflictException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'cart' => $this->payload($cart->refresh())], 409);
        }

        return response()->json($this->payload($this->cart($cartId)));
    }

    private function cart(int $cartId): Cart
    {
        $cart = Cart::query()
            ->where('store_id', app('current_store')->getKey())
            ->with(['lines.variant.product.media', 'lines.variant.inventory'])
            ->findOrFail($cartId);
        $customerId = request()->user('customer')?->getKey();

        if ($customerId !== null) {
            abort_unless((int) $cart->customer_id === (int) $customerId, 404);
        }

        return $cart;
    }

    /** @return array<string, mixed> */
    private function payload(Cart $cart): array
    {
        $lines = $cart->lines->map(fn ($line): array => ['id' => $line->id, 'variant_id' => $line->variant_id, 'product_title' => $line->variant->product->title, 'variant_title' => $line->variant->title, 'sku' => $line->variant->sku, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'line_subtotal_amount' => $line->line_subtotal_amount, 'line_discount_amount' => $line->line_discount_amount, 'line_total_amount' => $line->line_total_amount, 'image_url' => $line->variant->product->media->first()?->url, 'requires_shipping' => $line->variant->requires_shipping, 'available_quantity' => $line->variant->inventory?->availableQuantity() ?? 0])->values()->all();
        $subtotal = (int) collect($lines)->sum('line_total_amount');

        return ['id' => $cart->id, 'store_id' => $cart->store_id, 'customer_id' => $cart->customer_id, 'currency' => $cart->currency, 'cart_version' => $cart->cart_version, 'status' => $cart->status, 'lines' => $lines, 'totals' => ['subtotal' => $subtotal, 'discount' => 0, 'total' => $subtotal, 'currency' => $cart->currency, 'line_count' => count($lines), 'item_count' => (int) collect($lines)->sum('quantity')], 'created_at' => $cart->created_at, 'updated_at' => $cart->updated_at];
    }
}
<?php

use App\Jobs\ProcessMediaUpload;
use App\Livewire\Admin\Developers\Index as DeveloperSettings;
use App\Models\Organization;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\Store;
use App\Models\StoreInvitation;
use App\Models\User;
use App\Models\WebhookSubscription;
use Database\Seeders\ShopSeeder;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;

uses(LazilyRefreshDatabase::class);

beforeEach(function (): void {
    config(['cache.default' => 'array']);
    $this->seed(ShopSeeder::class);
    $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $this->admin = User::query()->where('email', 'admin@acme.test')->firstOrFail();
    app()->instance('current_store', $this->store);
});

test('platform API creates organizations, stores, invitations, and membership responses', function (): void {
    $token = $this->admin->createToken('platform-manager', ['manage-platform'])->plainTextToken;

    $organization = $this->withToken($token)->postJson('http://shop.test/api/admin/v1/platform/organizations', [
        'name' => 'Northwind',
        'billing_email' => 'billing@northwind.test',
    ])->assertCreated()->json('data');

    $createdStore = $this->withToken($token)->postJson('http://shop.test/api/admin/v1/platform/stores', [
        'organization_id' => $organization['id'],
        'name' => 'Northwind Shop',
        'handle' => 'northwind-shop',
        'default_currency' => 'EUR',
        'default_locale' => 'en',
        'timezone' => 'Europe/Berlin',
    ])->assertCreated()->json('data');

    $this->withToken($token)->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/invites", [
        'email' => 'new-staff@example.test',
        'role' => 'staff',
    ])->assertCreated()->assertJsonPath('data.role', 'staff');

    $this->withToken($this->admin->createToken('membership-reader', ['read-products'])->plainTextToken)
        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/me")
        ->assertOk()
        ->assertJsonPath('data.store_id', $this->store->getKey())
        ->assertJsonPath('data.role', 'owner');

    expect(Organization::query()->whereKey($organization['id'])->exists())->toBeTrue()
        ->and(Store::query()->whereKey($createdStore['id'])->value('handle'))->toBe('northwind-shop')
        ->and(StoreInvitation::query()->where('email', 'new-staff@example.test')->exists())->toBeTrue();
});

test('product API persists nested options variants inventory and collections', function (): void {
    $token = $this->admin->createToken('catalog-writer', ['write-products'])->plainTextToken;
    $collection = \App\Models\Collection::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'title' => 'Nested Collection', 'handle' => 'nested-collection', 'type' => 'manual', 'status' => 'active']);

    $response = $this->withToken($token)->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products", [
        'title' => 'Nested Catalog Product',
        'status' => 'draft',
        'options' => [['name' => 'Color', 'position' => 1]],
        'variants' => [[
            'sku' => 'NESTED-BLUE',
            'price_amount' => 2500,
            'currency' => 'EUR',
            'requires_shipping' => true,
            'is_default' => true,
            'option_values' => [['option_name' => 'Color', 'value' => 'Blue']],
            'inventory' => ['quantity_on_hand' => 50, 'policy' => 'deny'],
        ]],
        'collections' => [$collection->getKey()],
    ])->assertCreated();

    $product = Product::withoutGlobalScopes()->where('title', 'Nested Catalog Product')->with(['options.values', 'variants.inventory', 'variants.optionValues', 'collections'])->firstOrFail();

    expect($response->json('data.options.0.name'))->toBe('Color')
        ->and($product->variants->first()->inventory->quantity_on_hand)->toBe(50)
        ->and($product->variants->first()->optionValues->first()->value)->toBe('Blue')
        ->and($product->collections->contains($collection))->toBeTrue();

    $presigned = $this->withToken($token)->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products/{$product->getKey()}/media/presign-upload", [
        'filename' => 'product-image.jpg',
        'content_type' => 'image/jpeg',
        'byte_size' => 1200,
    ])->assertCreated()->json();

    expect($presigned['method'])->toBe('PUT')->and($presigned['media_id'])->toBeInt();
});

test('media processing records metadata, dimensions, derivatives, and failure state', function (): void {
    Storage::fake('public');
    $product = Product::query()->firstOrFail();
    $key = 'stores/'.$this->store->getKey().'/products/'.$product->getKey().'/media/source.jpg';
    $image = imagecreatetruecolor(640, 400);
    ob_start();
    imagejpeg($image, null, 90);
    $contents = ob_get_clean();
    imagedestroy($image);
    Storage::disk('public')->put($key, $contents);

    $media = ProductMedia::create(['product_id' => $product->getKey(), 'type' => 'image', 'path' => $key, 'storage_key' => $key, 'mime_type' => 'image/jpeg', 'status' => 'processing']);
    (new ProcessMediaUpload($media))->handle();

    expect($media->refresh()->status)->toBe('ready')
        ->and($media->width)->toBe(640)
        ->and($media->height)->toBe(400)
        ->and($media->metadata['variants'])->toHaveKeys(['original', 'thumbnail', 'small', 'medium', 'large'])
        ->and($media->checksum)->toBe(hash('sha256', $contents));

    $failed = ProductMedia::create(['product_id' => $product->getKey(), 'type' => 'image', 'path' => 'missing.jpg', 'status' => 'processing']);
    $exception = new RuntimeException('missing upload');
    (new ProcessMediaUpload($failed))->failed($exception);

    expect($failed->refresh()->status)->toBe('failed')
        ->and($failed->metadata['error'])->toBe('missing upload');
});

test('developer webhook creation writes the normalized event type', function (): void {
    $this->actingAs($this->admin);

    Livewire::test(DeveloperSettings::class)
        ->set('event', 'order.created')
        ->set('targetUrl', 'https://hooks.example.test/orders')
        ->call('createWebhook')
        ->assertHasNoErrors();

    expect(WebhookSubscription::query()->where('target_url', 'https://hooks.example.test/orders')->value('event_type'))->toBe('order.created');
});

test('deferred OAuth endpoints return not implemented responses', function (): void {
    $this->postJson('http://shop.test/oauth/token')->assertStatus(501);
});


exec

status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,260p' app/Models/Cart.php; sed -n '1,320p' app/Services/CartService.php; rg -n \"cart.*status|status.*cart|CartStatus|active\" app/Http/Controllers/Api/StorefrontCartController.php app/Services app/Models/Cart.php tests/Feature | head -120",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:20000});
text(r.output);


type: CommandExecution

command: sed -n '1,260p' app/Models/Cart.php; sed -n '1,320p' app/Services/CartService.php; rg -n "cart.*status|status.*cart|CartStatus|active" app/Http/Controllers/Api/StorefrontCartController.php app/Services app/Models/Cart.php tests/Feature | head -120

Cart.php

type: read

cmd: sed -n '1,260p' app/Models/Cart.php

CartService.php

type: search

cmd: rg -n 'cart.*status|status.*cart|CartStatus|active' app/Http/Controllers/Api/StorefrontCartController.php app/Services app/Models/Cart.php tests/Feature

query: cart.*status|status.*cart|CartStatus|active

status: completed

stdout:
<?php

namespace App\Models;

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

class Cart extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'customer_id', 'currency', 'cart_version', 'status', 'discount_code'];

    protected function casts(): array
    {
        return ['status' => CartStatus::class];
    }

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

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

    public function subtotalAmount(): int
    {
        return (int) $this->lines->sum('line_total_amount');
    }

    public function itemCount(): int
    {
        return (int) $this->lines->sum('quantity');
    }
}
<?php

namespace App\Services;

use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Exceptions\CartVersionConflictException;
use App\Exceptions\InsufficientInventoryException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Customer;
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' => 'active']);
    }

    public function addLine(Cart $cart, int $variantId, int $quantity): CartLine
    {
        if ($quantity < 1 || $quantity > 9999) {
            throw new \InvalidArgumentException('Quantity must be between 1 and 9999.');
        }

        return DB::transaction(function () use ($cart, $variantId, $quantity): CartLine {
            $cart = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($cart->getKey());
            $variant = ProductVariant::with(['product', 'inventory'])->findOrFail($variantId);

            if ($variant->product->store_id !== $cart->store_id || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
                abort(404);
            }

            $line = $cart->lines->firstWhere('variant_id', $variant->getKey());
            $newQuantity = ($line?->quantity ?? 0) + $quantity;

            if ($variant->inventory !== null && ! $variant->inventory->canSell($newQuantity)) {
                throw new InsufficientInventoryException;
            }

            $line ??= new CartLine(['cart_id' => $cart->getKey(), 'variant_id' => $variant->getKey()]);
            $line->fill([
                'quantity' => $newQuantity,
                'unit_price_amount' => $variant->price_amount,
                'line_subtotal_amount' => $variant->price_amount * $newQuantity,
                'line_discount_amount' => 0,
                'line_total_amount' => $variant->price_amount * $newQuantity,
            ])->save();
            $cart->increment('cart_version');

            return $line->load('variant.product');
        });
    }

    public function updateLineQuantity(Cart $cart, int $lineId, int $quantity): CartLine
    {
        if ($quantity < 1 || $quantity > 9999) {
            throw new \InvalidArgumentException('Quantity must be between 1 and 9999.');
        }

        return DB::transaction(function () use ($cart, $lineId, $quantity): CartLine {
            $line = $cart->lines()->with(['variant.inventory'])->findOrFail($lineId);

            if ($line->variant->inventory !== null && ! $line->variant->inventory->canSell($quantity)) {
                throw new InsufficientInventoryException;
            }

            $line->update(['quantity' => $quantity, 'line_subtotal_amount' => $line->unit_price_amount * $quantity, 'line_total_amount' => $line->unit_price_amount * $quantity]);
            $cart->increment('cart_version');

            return $line->refresh();
        });
    }

    public function removeLine(Cart $cart, int $lineId): void
    {
        DB::transaction(function () use ($cart, $lineId): void {
            $cart->lines()->findOrFail($lineId)->delete();
            $cart->increment('cart_version');
        });
    }

    public function assertVersion(Cart $cart, ?int $expectedVersion): void
    {
        if ($expectedVersion !== null && $expectedVersion !== $cart->cart_version) {
            throw new CartVersionConflictException;
        }
    }

    public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart
    {
        $key = 'cart_id_'.$store->getKey();
        $cart = $customer?->carts()->where('status', 'active')->latest()->first();

        if ($cart === null) {
            $sessionCartId = session($key, session('cart_id'));
            $cart = Cart::withoutGlobalScopes()->whereKey($sessionCartId)->where('store_id', $store->getKey())->where('status', 'active')->first();
        }

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

        return $cart->load(['lines.variant.product', 'lines.variant.inventory']);
    }

    public function mergeOnLogin(Cart $guest, Cart $customer): Cart
    {
        if ($guest->store_id !== $customer->store_id) {
            throw new \InvalidArgumentException('Carts must belong to the same store.');
        }

        DB::transaction(function () use ($guest, $customer): void {
            $guest = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines.variant.product')->findOrFail($guest->getKey());
            $customer = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($customer->getKey());
            $customerLines = $customer->lines->keyBy('variant_id');

            foreach ($guest->lines as $guestLine) {
                $variant = $guestLine->variant;

                if ($variant === null || $variant->product === null || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
                    continue;
                }

                $customerLine = $customerLines->get($guestLine->variant_id);
                $quantity = max((int) ($customerLine?->quantity ?? 0), (int) $guestLine->quantity);

                if ($variant->inventory !== null && ! $variant->inventory->canSell($quantity)) {
                    continue;
                }

                $attributes = [
                    'quantity' => $quantity,
                    'unit_price_amount' => $variant->price_amount,
                    'line_subtotal_amount' => $variant->price_amount * $quantity,
                    'line_discount_amount' => 0,
                    'line_total_amount' => $variant->price_amount * $quantity,
                ];

                if ($customerLine === null) {
                    $customerLine = $customer->lines()->create(['variant_id' => $variant->getKey(), ...$attributes]);
                    $customerLines->put($variant->getKey(), $customerLine);
                } else {
                    $customerLine->update($attributes);
                }

                $customer->increment('cart_version');
            }

            $guest->update(['status' => 'abandoned']);
            session()->forget(['cart_id_'.$guest->store_id, 'cart_id']);
        });

        return $customer->refresh()->load('lines');
    }
}
app/Models/Cart.php:5:use App\Enums\CartStatus;
app/Models/Cart.php:15:    protected $fillable = ['store_id', 'customer_id', 'currency', 'cart_version', 'status', 'discount_code'];
app/Models/Cart.php:19:        return ['status' => CartStatus::class];
app/Http/Controllers/Api/StorefrontCartController.php:106:        return ['id' => $cart->id, 'store_id' => $cart->store_id, 'customer_id' => $cart->customer_id, 'currency' => $cart->currency, 'cart_version' => $cart->cart_version, 'status' => $cart->status, 'lines' => $lines, 'totals' => ['subtotal' => $subtotal, 'discount' => 0, 'total' => $subtotal, 'currency' => $cart->currency, 'line_count' => count($lines), 'item_count' => (int) collect($lines)->sum('quantity')], 'created_at' => $cart->created_at, 'updated_at' => $cart->updated_at];
tests/Feature/OrderExportsAndDomainEventsTest.php:27:    $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'signing_secret_encrypted' => 'secret', 'status' => 'active']);
app/Services/DiscountService.php:23:        if ($discount->status !== 'active' || ($discount->ends_at !== null && $discount->ends_at->isPast())) {
app/Services/DiscountService.php:28:            throw new InvalidDiscountException('This discount code is not active yet.', 'discount_not_yet_active');
tests/Feature/CommerceFlowTest.php:92:    Discount::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'code' => null, 'type' => 'automatic', 'value_type' => 'percent', 'value_amount' => 10, 'status' => 'active', 'starts_at' => now()->subMinute(), 'ends_at' => now()->addDay(), 'rules_json' => []]);
tests/Feature/CommerceFlowTest.php:93:    Discount::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'code' => null, 'type' => 'automatic', 'value_type' => 'fixed', 'value_amount' => 100, 'status' => 'active', 'starts_at' => now()->subMinute(), 'ends_at' => now()->addDay(), 'rules_json' => []]);
tests/Feature/CommerceFlowTest.php:129:        'status' => 'active',
tests/Feature/ContractBehaviorTest.php:98:        'status' => 'active',
app/Services/CartService.php:22:        return Cart::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'customer_id' => $customer?->getKey(), 'currency' => $store->default_currency, 'cart_version' => 1, 'status' => 'active']);
app/Services/CartService.php:35:            if ($variant->product->store_id !== $cart->store_id || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
app/Services/CartService.php:98:        $cart = $customer?->carts()->where('status', 'active')->latest()->first();
app/Services/CartService.php:102:            $cart = Cart::withoutGlobalScopes()->whereKey($sessionCartId)->where('store_id', $store->getKey())->where('status', 'active')->first();
tests/Feature/PlatformApiAndMediaTest.php:62:    $collection = \App\Models\Collection::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'title' => 'Nested Collection', 'handle' => 'nested-collection', 'type' => 'manual', 'status' => 'active']);
app/Services/ShippingCalculator.php:17:        $rates = ShippingRate::query()->where('is_active', true)->whereHas('zone', fn ($query) => $query->where('store_id', $store->getKey()))->with('zone')->get();
tests/Feature/SearchAnalyticsWebhookTest.php:80:    $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'signing_secret_encrypted' => 'test-secret', 'status' => 'active']);
app/Services/ProductService.php:45:                throw new InvalidProductTransitionException('An active product requires a title and a priced variant.');
app/Services/ProductService.php:98:            throw new InvalidProductTransitionException('An active product requires a title and a priced variant.');
tests/Feature/CommerceValidationAndCleanupTest.php:120:    $activeCart = $carts->create($this->store);
tests/Feature/CommerceValidationAndCleanupTest.php:121:    $carts->addLine($activeCart, $this->variant->getKey(), 1);
tests/Feature/CommerceValidationAndCleanupTest.php:122:    $activeCheckout = Checkout::withoutGlobalScopes()->create([
tests/Feature/CommerceValidationAndCleanupTest.php:124:        'cart_id' => $activeCart->getKey(),
tests/Feature/CommerceValidationAndCleanupTest.php:125:        'email' => 'active@example.test',
tests/Feature/CommerceValidationAndCleanupTest.php:135:        ->and($activeCheckout->refresh()->status)->toBe(CheckoutStatus::PaymentSelected)
app/Services/CheckoutService.php:29:        return Checkout::create(['store_id' => $cart->store_id, 'cart_id' => $cart->getKey(), 'customer_id' => $customer?->getKey(), 'status' => CheckoutStatus::Started, 'email' => $email, 'discount_code' => $cart->discount_code, 'expires_at' => now()->addHours(24)]);
app/Services/OrderService.php:39:                    ['first_name' => 'Guest', 'last_name' => 'Customer', 'status' => 'active', 'metadata' => ['guest_checkout' => true]],
app/Services/OrderService.php:91:            $checkout->cart->update(['status' => 'converted']);
app/Services/WebhookService.php:18:            ->where('status', 'active')


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Models;

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

class Cart extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'customer_id', 'currency', 'cart_version', 'status', 'discount_code'];

    protected function casts(): array
    {
        return ['status' => CartStatus::class];
    }

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

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

    public function subtotalAmount(): int
    {
        return (int) $this->lines->sum('line_total_amount');
    }

    public function itemCount(): int
    {
        return (int) $this->lines->sum('quantity');
    }
}
<?php

namespace App\Services;

use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Exceptions\CartVersionConflictException;
use App\Exceptions\InsufficientInventoryException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Customer;
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' => 'active']);
    }

    public function addLine(Cart $cart, int $variantId, int $quantity): CartLine
    {
        if ($quantity < 1 || $quantity > 9999) {
            throw new \InvalidArgumentException('Quantity must be between 1 and 9999.');
        }

        return DB::transaction(function () use ($cart, $variantId, $quantity): CartLine {
            $cart = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($cart->getKey());
            $variant = ProductVariant::with(['product', 'inventory'])->findOrFail($variantId);

            if ($variant->product->store_id !== $cart->store_id || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
                abort(404);
            }

            $line = $cart->lines->firstWhere('variant_id', $variant->getKey());
            $newQuantity = ($line?->quantity ?? 0) + $quantity;

            if ($variant->inventory !== null && ! $variant->inventory->canSell($newQuantity)) {
                throw new InsufficientInventoryException;
            }

            $line ??= new CartLine(['cart_id' => $cart->getKey(), 'variant_id' => $variant->getKey()]);
            $line->fill([
                'quantity' => $newQuantity,
                'unit_price_amount' => $variant->price_amount,
                'line_subtotal_amount' => $variant->price_amount * $newQuantity,
                'line_discount_amount' => 0,
                'line_total_amount' => $variant->price_amount * $newQuantity,
            ])->save();
            $cart->increment('cart_version');

            return $line->load('variant.product');
        });
    }

    public function updateLineQuantity(Cart $cart, int $lineId, int $quantity): CartLine
    {
        if ($quantity < 1 || $quantity > 9999) {
            throw new \InvalidArgumentException('Quantity must be between 1 and 9999.');
        }

        return DB::transaction(function () use ($cart, $lineId, $quantity): CartLine {
            $line = $cart->lines()->with(['variant.inventory'])->findOrFail($lineId);

            if ($line->variant->inventory !== null && ! $line->variant->inventory->canSell($quantity)) {
                throw new InsufficientInventoryException;
            }

            $line->update(['quantity' => $quantity, 'line_subtotal_amount' => $line->unit_price_amount * $quantity, 'line_total_amount' => $line->unit_price_amount * $quantity]);
            $cart->increment('cart_version');

            return $line->refresh();
        });
    }

    public function removeLine(Cart $cart, int $lineId): void
    {
        DB::transaction(function () use ($cart, $lineId): void {
            $cart->lines()->findOrFail($lineId)->delete();
            $cart->increment('cart_version');
        });
    }

    public function assertVersion(Cart $cart, ?int $expectedVersion): void
    {
        if ($expectedVersion !== null && $expectedVersion !== $cart->cart_version) {
            throw new CartVersionConflictException;
        }
    }

    public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart
    {
        $key = 'cart_id_'.$store->getKey();
        $cart = $customer?->carts()->where('status', 'active')->latest()->first();

        if ($cart === null) {
            $sessionCartId = session($key, session('cart_id'));
            $cart = Cart::withoutGlobalScopes()->whereKey($sessionCartId)->where('store_id', $store->getKey())->where('status', 'active')->first();
        }

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

        return $cart->load(['lines.variant.product', 'lines.variant.inventory']);
    }

    public function mergeOnLogin(Cart $guest, Cart $customer): Cart
    {
        if ($guest->store_id !== $customer->store_id) {
            throw new \InvalidArgumentException('Carts must belong to the same store.');
        }

        DB::transaction(function () use ($guest, $customer): void {
            $guest = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines.variant.product')->findOrFail($guest->getKey());
            $customer = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($customer->getKey());
            $customerLines = $customer->lines->keyBy('variant_id');

            foreach ($guest->lines as $guestLine) {
                $variant = $guestLine->variant;

                if ($variant === null || $variant->product === null || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
                    continue;
                }

                $customerLine = $customerLines->get($guestLine->variant_id);
                $quantity = max((int) ($customerLine?->quantity ?? 0), (int) $guestLine->quantity);

                if ($variant->inventory !== null && ! $variant->inventory->canSell($quantity)) {
                    continue;
                }

                $attributes = [
                    'quantity' => $quantity,
                    'unit_price_amount' => $variant->price_amount,
                    'line_subtotal_amount' => $variant->price_amount * $quantity,
                    'line_discount_amount' => 0,
                    'line_total_amount' => $variant->price_amount * $quantity,
                ];

                if ($customerLine === null) {
                    $customerLine = $customer->lines()->create(['variant_id' => $variant->getKey(), ...$attributes]);
                    $customerLines->put($variant->getKey(), $customerLine);
                } else {
                    $customerLine->update($attributes);
                }

                $customer->increment('cart_version');
            }

            $guest->update(['status' => 'abandoned']);
            session()->forget(['cart_id_'.$guest->store_id, 'cart_id']);
        });

        return $customer->refresh()->load('lines');
    }
}
app/Models/Cart.php:5:use App\Enums\CartStatus;
app/Models/Cart.php:15:    protected $fillable = ['store_id', 'customer_id', 'currency', 'cart_version', 'status', 'discount_code'];
app/Models/Cart.php:19:        return ['status' => CartStatus::class];
app/Http/Controllers/Api/StorefrontCartController.php:106:        return ['id' => $cart->id, 'store_id' => $cart->store_id, 'customer_id' => $cart->customer_id, 'currency' => $cart->currency, 'cart_version' => $cart->cart_version, 'status' => $cart->status, 'lines' => $lines, 'totals' => ['subtotal' => $subtotal, 'discount' => 0, 'total' => $subtotal, 'currency' => $cart->currency, 'line_count' => count($lines), 'item_count' => (int) collect($lines)->sum('quantity')], 'created_at' => $cart->created_at, 'updated_at' => $cart->updated_at];
tests/Feature/OrderExportsAndDomainEventsTest.php:27:    $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'signing_secret_encrypted' => 'secret', 'status' => 'active']);
app/Services/DiscountService.php:23:        if ($discount->status !== 'active' || ($discount->ends_at !== null && $discount->ends_at->isPast())) {
app/Services/DiscountService.php:28:            throw new InvalidDiscountException('This discount code is not active yet.', 'discount_not_yet_active');
tests/Feature/CommerceFlowTest.php:92:    Discount::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'code' => null, 'type' => 'automatic', 'value_type' => 'percent', 'value_amount' => 10, 'status' => 'active', 'starts_at' => now()->subMinute(), 'ends_at' => now()->addDay(), 'rules_json' => []]);
tests/Feature/CommerceFlowTest.php:93:    Discount::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'code' => null, 'type' => 'automatic', 'value_type' => 'fixed', 'value_amount' => 100, 'status' => 'active', 'starts_at' => now()->subMinute(), 'ends_at' => now()->addDay(), 'rules_json' => []]);
tests/Feature/CommerceFlowTest.php:129:        'status' => 'active',
tests/Feature/ContractBehaviorTest.php:98:        'status' => 'active',
app/Services/CartService.php:22:        return Cart::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'customer_id' => $customer?->getKey(), 'currency' => $store->default_currency, 'cart_version' => 1, 'status' => 'active']);
app/Services/CartService.php:35:            if ($variant->product->store_id !== $cart->store_id || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
app/Services/CartService.php:98:        $cart = $customer?->carts()->where('status', 'active')->latest()->first();
app/Services/CartService.php:102:            $cart = Cart::withoutGlobalScopes()->whereKey($sessionCartId)->where('store_id', $store->getKey())->where('status', 'active')->first();
tests/Feature/PlatformApiAndMediaTest.php:62:    $collection = \App\Models\Collection::withoutGlobalScopes()->create(['store_id' => $this->store->getKey(), 'title' => 'Nested Collection', 'handle' => 'nested-collection', 'type' => 'manual', 'status' => 'active']);
app/Services/ShippingCalculator.php:17:        $rates = ShippingRate::query()->where('is_active', true)->whereHas('zone', fn ($query) => $query->where('store_id', $store->getKey()))->with('zone')->get();
tests/Feature/SearchAnalyticsWebhookTest.php:80:    $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'signing_secret_encrypted' => 'test-secret', 'status' => 'active']);
app/Services/ProductService.php:45:                throw new InvalidProductTransitionException('An active product requires a title and a priced variant.');
app/Services/ProductService.php:98:            throw new InvalidProductTransitionException('An active product requires a title and a priced variant.');
tests/Feature/CommerceValidationAndCleanupTest.php:120:    $activeCart = $carts->create($this->store);
tests/Feature/CommerceValidationAndCleanupTest.php:121:    $carts->addLine($activeCart, $this->variant->getKey(), 1);
tests/Feature/CommerceValidationAndCleanupTest.php:122:    $activeCheckout = Checkout::withoutGlobalScopes()->create([
tests/Feature/CommerceValidationAndCleanupTest.php:124:        'cart_id' => $activeCart->getKey(),
tests/Feature/CommerceValidationAndCleanupTest.php:125:        'email' => 'active@example.test',
tests/Feature/CommerceValidationAndCleanupTest.php:135:        ->and($activeCheckout->refresh()->status)->toBe(CheckoutStatus::PaymentSelected)
app/Services/CheckoutService.php:29:        return Checkout::create(['store_id' => $cart->store_id, 'cart_id' => $cart->getKey(), 'customer_id' => $customer?->getKey(), 'status' => CheckoutStatus::Started, 'email' => $email, 'discount_code' => $cart->discount_code, 'expires_at' => now()->addHours(24)]);
app/Services/OrderService.php:39:                    ['first_name' => 'Guest', 'last_name' => 'Customer', 'status' => 'active', 'metadata' => ['guest_checkout' => true]],
app/Services/OrderService.php:91:            $checkout->cart->update(['status' => 'converted']);
app/Services/WebhookService.php:18:            ->where('status', 'active')


exec

status: completed

input:
const r = await tools.exec_command({cmd:"cat app/Enums/CartStatus.php; sed -n '1,280p' app/Http/Controllers/Api/StorefrontCheckoutController.php; rg -n \"StorefrontCartController|/carts|cart\" tests/Feature | head -100",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:18000});
text(r.output);


command: cat app/Enums/CartStatus.php; sed -n '1,280p' app/Http/Controllers/Api/StorefrontCheckoutController.php; rg -n "StorefrontCartController|/carts|cart" tests/Feature | head -100

CartStatus.php

cmd: cat app/Enums/CartStatus.php

StorefrontCheckoutController.php

type: search

cmd: rg -n 'StorefrontCartController|/carts|cart' tests/Feature

query: StorefrontCartController|/carts|cart

path: Feature

stdout:
<?php

namespace App\Enums;

enum CartStatus: string
{
    case Active = 'active';
    case Converted = 'converted';
    case Abandoned = 'abandoned';
}
<?php

namespace App\Http\Controllers\Api;

use App\Enums\PaymentMethod;
use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidDiscountException;
use App\Exceptions\PaymentDeclinedException;
use App\Http\Controllers\Controller;
use App\Http\Requests\ApplyDiscountRequest;
use App\Http\Requests\SetCheckoutAddressRequest;
use App\Models\Cart;
use App\Models\Checkout;
use App\Services\CheckoutService;
use App\Services\DiscountService;
use App\Services\PaymentService;
use App\Services\PricingEngine;
use App\Services\ShippingCalculator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StorefrontCheckoutController extends Controller
{
    public function __construct(private readonly CheckoutService $checkouts, private readonly PricingEngine $pricing, private readonly ShippingCalculator $shipping, private readonly DiscountService $discounts, private readonly PaymentService $payments) {}

    public function store(Request $request): JsonResponse
    {
        $data = $request->validate(['cart_id' => ['required', 'integer'], 'email' => ['required', 'email']]);
        $cart = Cart::query()->where('status', 'active')->with('lines')->findOrFail($data['cart_id']);
        $customerId = $request->user('customer')?->getKey();
        if ($customerId !== null) {
            abort_unless((int) $cart->customer_id === (int) $customerId, 404);
        } else {
            $sessionCartIds = array_filter([$request->session()->get('cart_id'), $request->session()->get('cart_id_'.app('current_store')->getKey())]);
            abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404);
        }
        $checkout = $this->checkouts->create($cart, $data['email'], $request->user('customer'));
        $this->pricing->calculate($checkout);

        return response()->json($this->payload($checkout->refresh()), 201);
    }

    public function show(int $checkoutId): JsonResponse
    {
        $checkout = $this->checkout($checkoutId);

        if ($checkout->isExpired()) {
            return response()->json(['message' => 'Checkout expired.'], 410);
        }

        return response()->json($this->payload($checkout));
    }

    public function address(SetCheckoutAddressRequest $request, int $checkoutId): JsonResponse
    {
        $checkout = $this->checkout($checkoutId);
        $useShippingAsBilling = $request->boolean('use_shipping_as_billing', true);
        $data = $request->validated();
        try {
            $checkout = $this->checkouts->setAddress($checkout, $data['shipping_address'] ?? [], $data['billing_address'] ?? null, $useShippingAsBilling);
        } catch (\LogicException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 409);
        } catch (PaymentDeclinedException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->errorCode], 422);
        }

        return response()->json($this->payload($checkout));
    }

    public function shippingMethod(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['shipping_method_id' => ['required', 'integer'], 'shipping_rate_id' => ['nullable', 'integer']]);
        try {
            $checkout = $this->checkouts->setShippingMethod($this->checkout($checkoutId), $data['shipping_rate_id'] ?? $data['shipping_method_id']);
        } catch (\LogicException|\InvalidArgumentException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422);
        }

        return response()->json($this->payload($checkout));
    }

    public function applyDiscount(ApplyDiscountRequest $request, int $checkoutId): JsonResponse
    {
        $data = $request->validated();
        $checkout = $this->checkout($checkoutId);
        try {
            $discount = $this->discounts->validate($data['code'], app('current_store'), $checkout->cart);
        } catch (InvalidDiscountException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => $exception->reason], 422);
        }
        $checkout->update(['discount_code' => $discount->code]);
        $this->pricing->calculate($checkout->refresh());

        return response()->json($this->payload($checkout->refresh()));
    }

    public function removeDiscount(int $checkoutId): JsonResponse
    {
        $checkout = $this->checkout($checkoutId);
        $checkout->update(['discount_code' => null]);
        $this->pricing->calculate($checkout->refresh());

        return response()->json($this->payload($checkout->refresh()));
    }

    public function pay(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate([
            'payment_method' => ['required', 'string', 'in:credit_card,paypal,bank_transfer'],
            'card_number' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^(?=.*\d)[0-9 ]+$/'],
            'card_expiry' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^(0[1-9]|1[0-2])\/\d{2}$/'],
            'card_cvc' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^\d{3,4}$/'],
            'card_holder' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'max:255'],
        ]);
        try {
            $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']);
            $order = $this->payments->pay($checkout, PaymentMethod::from($data['payment_method']), $data);
        } catch (InsufficientInventoryException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422);
        } catch (PaymentDeclinedException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->errorCode], 422);
        } catch (\LogicException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422);
        }

        $order = $order->load('checkout');
        $payload = ['checkout_id' => $order->checkout_id, 'status' => 'completed', 'order' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'payment_method' => $order->payment_method, 'total_amount' => $order->total_amount, 'currency' => $order->currency]];

        if ($order->financial_status->value === 'pending') {
            $payload['bank_transfer_instructions'] = ['bank_name' => 'Mock Bank AG', 'iban' => 'DE89 3704 0044 0532 0130 00', 'bic' => 'COBADEFFXXX', 'reference' => $order->order_number, 'amount_formatted' => number_format($order->total_amount / 100, 2, '.', '').' '.$order->currency];
        }

        return response()->json($payload);
    }

    public function paymentMethod(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer']]);
        try {
            $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']);
        } catch (InsufficientInventoryException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422);
        } catch (\LogicException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422);
        }

        return response()->json($this->payload($checkout));
    }

    private function checkout(int $checkoutId): Checkout
    {
        $checkout = Checkout::query()->with(['cart.lines.variant.product', 'shippingRate'])->findOrFail($checkoutId);
        $customerId = request()->user('customer')?->getKey();

        if ($customerId !== null) {
            abort_unless((int) $checkout->customer_id === (int) $customerId, 404);
        } else {
            $sessionCartIds = array_filter([request()->session()->get('cart_id'), request()->session()->get('cart_id_'.app('current_store')->getKey())]);
            abort_unless(in_array($checkout->cart_id, $sessionCartIds, true), 404);
        }

        return $checkout;
    }

    /** @return array<string, mixed> */
    private function payload(Checkout $checkout): array
    {
        $totals = $checkout->totals_json ?? $this->pricing->calculate($checkout)->toArray();
        $rates = $checkout->shipping_address_json === null ? collect() : $this->shipping->getAvailableRates(app('current_store'), $checkout->shipping_address_json);

        return ['id' => $checkout->id, 'store_id' => $checkout->store_id, 'cart_id' => $checkout->cart_id, 'customer_id' => $checkout->customer_id, 'status' => $checkout->status, 'email' => $checkout->email, 'payment_method' => $checkout->payment_method, 'shipping_address_json' => $checkout->shipping_address_json, 'billing_address_json' => $checkout->billing_address_json, 'shipping_method_id' => $checkout->shipping_rate_id, 'discount_code' => $checkout->discount_code, 'lines' => $checkout->cart->lines->map(fn ($line): array => ['variant_id' => $line->variant_id, 'product_title' => $line->variant->product->title, 'variant_title' => $line->variant->title, 'sku' => $line->variant->sku, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'line_total_amount' => $line->line_total_amount])->all(), 'totals' => $totals, 'available_shipping_methods' => $rates->map(fn ($rate): array => ['id' => $rate->id, 'name' => $rate->name, 'type' => $rate->type, 'price_amount' => $rate->price_amount, 'currency' => $rate->currency, 'estimated_days_min' => $rate->estimated_days_min, 'estimated_days_max' => $rate->estimated_days_max])->all(), 'expires_at' => $checkout->expires_at, 'created_at' => $checkout->created_at];
    }
}
tests/Feature/CommerceFlowTest.php:22:test('guest cart and card checkout create a paid order', function (): void {
tests/Feature/CommerceFlowTest.php:25:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts', ['currency' => 'EUR'])
tests/Feature/CommerceFlowTest.php:29:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
tests/Feature/CommerceFlowTest.php:32:        'cart_version' => 1,
tests/Feature/CommerceFlowTest.php:36:        'cart_id' => $cart['id'],
tests/Feature/CommerceFlowTest.php:77:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
tests/Feature/CommerceFlowTest.php:78:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
tests/Feature/CommerceFlowTest.php:79:    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'declined@example.test'])->json();
tests/Feature/CommerceFlowTest.php:95:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
tests/Feature/CommerceFlowTest.php:96:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
tests/Feature/CommerceFlowTest.php:97:    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'automatic@example.test'])
tests/Feature/CommerceFlowTest.php:105:test('stale cart versions return a conflict response', function (): void {
tests/Feature/CommerceFlowTest.php:107:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
tests/Feature/CommerceFlowTest.php:109:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertCreated();
tests/Feature/CommerceFlowTest.php:110:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertConflict();
tests/Feature/CommerceFlowTest.php:113:test('guest cart ids authorize stateless access within the current tenant', function (): void {
tests/Feature/CommerceFlowTest.php:114:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
tests/Feature/CommerceFlowTest.php:118:    $this->getJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}")
tests/Feature/CommerceFlowTest.php:120:        ->assertJsonPath('id', $cart['id']);
tests/Feature/CommerceFlowTest.php:123:test('guest cart ids cannot cross tenant boundaries', function (): void {
tests/Feature/CommerceFlowTest.php:128:        'cart_version' => 1,
tests/Feature/CommerceFlowTest.php:134:    $this->getJson("http://shop.test/api/storefront/v1/carts/{$otherCart->getKey()}")
tests/Feature/CommerceFlowTest.php:138:test('cart and checkout APIs return domain errors as unprocessable responses', function (): void {
tests/Feature/CommerceFlowTest.php:140:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
tests/Feature/CommerceFlowTest.php:142:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
tests/Feature/CommerceFlowTest.php:145:        'cart_version' => 1,
tests/Feature/CommerceFlowTest.php:149:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertCreated();
tests/Feature/CommerceFlowTest.php:150:    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'discount-error@example.test'])->assertCreated()->json();
tests/Feature/CommerceFlowTest.php:159:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
tests/Feature/CommerceFlowTest.php:160:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
tests/Feature/CommerceFlowTest.php:161:    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'bank@example.test'])->json();
tests/Feature/SearchAnalyticsWebhookTest.php:62:    foreach (['page_view', 'page_view', 'add_to_cart', 'checkout_started'] as $type) {
tests/Feature/SearchAnalyticsWebhookTest.php:73:        ->and($daily->add_to_cart_count)->toBe(1)
tests/Feature/CommerceValidationAndCleanupTest.php:30:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
tests/Feature/CommerceValidationAndCleanupTest.php:31:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
tests/Feature/CommerceValidationAndCleanupTest.php:34:        'cart_version' => 1,
tests/Feature/CommerceValidationAndCleanupTest.php:37:        'cart_id' => $cart['id'],
tests/Feature/CommerceValidationAndCleanupTest.php:70:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
tests/Feature/CommerceValidationAndCleanupTest.php:71:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
tests/Feature/CommerceValidationAndCleanupTest.php:74:        'cart_version' => 1,
tests/Feature/CommerceValidationAndCleanupTest.php:77:        'cart_id' => $cart['id'],
tests/Feature/CommerceValidationAndCleanupTest.php:103:test('abandoned cart cleanup does not release inventory owned by another checkout', function (): void {
tests/Feature/CommerceValidationAndCleanupTest.php:104:    $carts = app(CartService::class);
tests/Feature/CommerceValidationAndCleanupTest.php:107:    $expiredCart = $carts->create($this->store);
tests/Feature/CommerceValidationAndCleanupTest.php:108:    $carts->addLine($expiredCart, $this->variant->getKey(), 1);
tests/Feature/CommerceValidationAndCleanupTest.php:111:        'cart_id' => $expiredCart->getKey(),
tests/Feature/CommerceValidationAndCleanupTest.php:120:    $activeCart = $carts->create($this->store);
tests/Feature/CommerceValidationAndCleanupTest.php:121:    $carts->addLine($activeCart, $this->variant->getKey(), 1);
tests/Feature/CommerceValidationAndCleanupTest.php:124:        'cart_id' => $activeCart->getKey(),
tests/Feature/CommerceValidationAndCleanupTest.php:130:    DB::table('carts')->where('id', $expiredCart->getKey())->update(['updated_at' => now()->subDays(15)]);
tests/Feature/Storefront/CustomerAuthenticationTest.php:38:        ->postJson('http://shop.test/api/storefront/v1/carts')


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Enums;

enum CartStatus: string
{
    case Active = 'active';
    case Converted = 'converted';
    case Abandoned = 'abandoned';
}
<?php

namespace App\Http\Controllers\Api;

use App\Enums\PaymentMethod;
use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidDiscountException;
use App\Exceptions\PaymentDeclinedException;
use App\Http\Controllers\Controller;
use App\Http\Requests\ApplyDiscountRequest;
use App\Http\Requests\SetCheckoutAddressRequest;
use App\Models\Cart;
use App\Models\Checkout;
use App\Services\CheckoutService;
use App\Services\DiscountService;
use App\Services\PaymentService;
use App\Services\PricingEngine;
use App\Services\ShippingCalculator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StorefrontCheckoutController extends Controller
{
    public function __construct(private readonly CheckoutService $checkouts, private readonly PricingEngine $pricing, private readonly ShippingCalculator $shipping, private readonly DiscountService $discounts, private readonly PaymentService $payments) {}

    public function store(Request $request): JsonResponse
    {
        $data = $request->validate(['cart_id' => ['required', 'integer'], 'email' => ['required', 'email']]);
        $cart = Cart::query()->where('status', 'active')->with('lines')->findOrFail($data['cart_id']);
        $customerId = $request->user('customer')?->getKey();
        if ($customerId !== null) {
            abort_unless((int) $cart->customer_id === (int) $customerId, 404);
        } else {
            $sessionCartIds = array_filter([$request->session()->get('cart_id'), $request->session()->get('cart_id_'.app('current_store')->getKey())]);
            abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404);
        }
        $checkout = $this->checkouts->create($cart, $data['email'], $request->user('customer'));
        $this->pricing->calculate($checkout);

        return response()->json($this->payload($checkout->refresh()), 201);
    }

    public function show(int $checkoutId): JsonResponse
    {
        $checkout = $this->checkout($checkoutId);

        if ($checkout->isExpired()) {
            return response()->json(['message' => 'Checkout expired.'], 410);
        }

        return response()->json($this->payload($checkout));
    }

    public function address(SetCheckoutAddressRequest $request, int $checkoutId): JsonResponse
    {
        $checkout = $this->checkout($checkoutId);
        $useShippingAsBilling = $request->boolean('use_shipping_as_billing', true);
        $data = $request->validated();
        try {
            $checkout = $this->checkouts->setAddress($checkout, $data['shipping_address'] ?? [], $data['billing_address'] ?? null, $useShippingAsBilling);
        } catch (\LogicException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 409);
        } catch (PaymentDeclinedException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->errorCode], 422);
        }

        return response()->json($this->payload($checkout));
    }

    public function shippingMethod(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['shipping_method_id' => ['required', 'integer'], 'shipping_rate_id' => ['nullable', 'integer']]);
        try {
            $checkout = $this->checkouts->setShippingMethod($this->checkout($checkoutId), $data['shipping_rate_id'] ?? $data['shipping_method_id']);
        } catch (\LogicException|\InvalidArgumentException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422);
        }

        return response()->json($this->payload($checkout));
    }

    public function applyDiscount(ApplyDiscountRequest $request, int $checkoutId): JsonResponse
    {
        $data = $request->validated();
        $checkout = $this->checkout($checkoutId);
        try {
            $discount = $this->discounts->validate($data['code'], app('current_store'), $checkout->cart);
        } catch (InvalidDiscountException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => $exception->reason], 422);
        }
        $checkout->update(['discount_code' => $discount->code]);
        $this->pricing->calculate($checkout->refresh());

        return response()->json($this->payload($checkout->refresh()));
    }

    public function removeDiscount(int $checkoutId): JsonResponse
    {
        $checkout = $this->checkout($checkoutId);
        $checkout->update(['discount_code' => null]);
        $this->pricing->calculate($checkout->refresh());

        return response()->json($this->payload($checkout->refresh()));
    }

    public function pay(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate([
            'payment_method' => ['required', 'string', 'in:credit_card,paypal,bank_transfer'],
            'card_number' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^(?=.*\d)[0-9 ]+$/'],
            'card_expiry' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^(0[1-9]|1[0-2])\/\d{2}$/'],
            'card_cvc' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'regex:/^\d{3,4}$/'],
            'card_holder' => ['exclude_unless:payment_method,credit_card', 'required', 'string', 'max:255'],
        ]);
        try {
            $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']);
            $order = $this->payments->pay($checkout, PaymentMethod::from($data['payment_method']), $data);
        } catch (InsufficientInventoryException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422);
        } catch (PaymentDeclinedException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->errorCode], 422);
        } catch (\LogicException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422);
        }

        $order = $order->load('checkout');
        $payload = ['checkout_id' => $order->checkout_id, 'status' => 'completed', 'order' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'payment_method' => $order->payment_method, 'total_amount' => $order->total_amount, 'currency' => $order->currency]];

        if ($order->financial_status->value === 'pending') {
            $payload['bank_transfer_instructions'] = ['bank_name' => 'Mock Bank AG', 'iban' => 'DE89 3704 0044 0532 0130 00', 'bic' => 'COBADEFFXXX', 'reference' => $order->order_number, 'amount_formatted' => number_format($order->total_amount / 100, 2, '.', '').' '.$order->currency];
        }

        return response()->json($payload);
    }

    public function paymentMethod(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer']]);
        try {
            $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']);
        } catch (InsufficientInventoryException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422);
        } catch (\LogicException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422);
        }

        return response()->json($this->payload($checkout));
    }

    private function checkout(int $checkoutId): Checkout
    {
        $checkout = Checkout::query()->with(['cart.lines.variant.product', 'shippingRate'])->findOrFail($checkoutId);
        $customerId = request()->user('customer')?->getKey();

        if ($customerId !== null) {
            abort_unless((int) $checkout->customer_id === (int) $customerId, 404);
        } else {
            $sessionCartIds = array_filter([request()->session()->get('cart_id'), request()->session()->get('cart_id_'.app('current_store')->getKey())]);
            abort_unless(in_array($checkout->cart_id, $sessionCartIds, true), 404);
        }

        return $checkout;
    }

    /** @return array<string, mixed> */
    private function payload(Checkout $checkout): array
    {
        $totals = $checkout->totals_json ?? $this->pricing->calculate($checkout)->toArray();
        $rates = $checkout->shipping_address_json === null ? collect() : $this->shipping->getAvailableRates(app('current_store'), $checkout->shipping_address_json);

        return ['id' => $checkout->id, 'store_id' => $checkout->store_id, 'cart_id' => $checkout->cart_id, 'customer_id' => $checkout->customer_id, 'status' => $checkout->status, 'email' => $checkout->email, 'payment_method' => $checkout->payment_method, 'shipping_address_json' => $checkout->shipping_address_json, 'billing_address_json' => $checkout->billing_address_json, 'shipping_method_id' => $checkout->shipping_rate_id, 'discount_code' => $checkout->discount_code, 'lines' => $checkout->cart->lines->map(fn ($line): array => ['variant_id' => $line->variant_id, 'product_title' => $line->variant->product->title, 'variant_title' => $line->variant->title, 'sku' => $line->variant->sku, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'line_total_amount' => $line->line_total_amount])->all(), 'totals' => $totals, 'available_shipping_methods' => $rates->map(fn ($rate): array => ['id' => $rate->id, 'name' => $rate->name, 'type' => $rate->type, 'price_amount' => $rate->price_amount, 'currency' => $rate->currency, 'estimated_days_min' => $rate->estimated_days_min, 'estimated_days_max' => $rate->estimated_days_max])->all(), 'expires_at' => $checkout->expires_at, 'created_at' => $checkout->created_at];
    }
}
tests/Feature/CommerceFlowTest.php:22:test('guest cart and card checkout create a paid order', function (): void {
tests/Feature/CommerceFlowTest.php:25:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts', ['currency' => 'EUR'])
tests/Feature/CommerceFlowTest.php:29:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
tests/Feature/CommerceFlowTest.php:32:        'cart_version' => 1,
tests/Feature/CommerceFlowTest.php:36:        'cart_id' => $cart['id'],
tests/Feature/CommerceFlowTest.php:77:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
tests/Feature/CommerceFlowTest.php:78:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
tests/Feature/CommerceFlowTest.php:79:    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'declined@example.test'])->json();
tests/Feature/CommerceFlowTest.php:95:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
tests/Feature/CommerceFlowTest.php:96:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
tests/Feature/CommerceFlowTest.php:97:    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'automatic@example.test'])
tests/Feature/CommerceFlowTest.php:105:test('stale cart versions return a conflict response', function (): void {
tests/Feature/CommerceFlowTest.php:107:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
tests/Feature/CommerceFlowTest.php:109:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertCreated();
tests/Feature/CommerceFlowTest.php:110:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertConflict();
tests/Feature/CommerceFlowTest.php:113:test('guest cart ids authorize stateless access within the current tenant', function (): void {
tests/Feature/CommerceFlowTest.php:114:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
tests/Feature/CommerceFlowTest.php:118:    $this->getJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}")
tests/Feature/CommerceFlowTest.php:120:        ->assertJsonPath('id', $cart['id']);
tests/Feature/CommerceFlowTest.php:123:test('guest cart ids cannot cross tenant boundaries', function (): void {
tests/Feature/CommerceFlowTest.php:128:        'cart_version' => 1,
tests/Feature/CommerceFlowTest.php:134:    $this->getJson("http://shop.test/api/storefront/v1/carts/{$otherCart->getKey()}")
tests/Feature/CommerceFlowTest.php:138:test('cart and checkout APIs return domain errors as unprocessable responses', function (): void {
tests/Feature/CommerceFlowTest.php:140:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
tests/Feature/CommerceFlowTest.php:142:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
tests/Feature/CommerceFlowTest.php:145:        'cart_version' => 1,
tests/Feature/CommerceFlowTest.php:149:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertCreated();
tests/Feature/CommerceFlowTest.php:150:    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'discount-error@example.test'])->assertCreated()->json();
tests/Feature/CommerceFlowTest.php:159:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
tests/Feature/CommerceFlowTest.php:160:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
tests/Feature/CommerceFlowTest.php:161:    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'bank@example.test'])->json();
tests/Feature/SearchAnalyticsWebhookTest.php:62:    foreach (['page_view', 'page_view', 'add_to_cart', 'checkout_started'] as $type) {
tests/Feature/SearchAnalyticsWebhookTest.php:73:        ->and($daily->add_to_cart_count)->toBe(1)
tests/Feature/CommerceValidationAndCleanupTest.php:30:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
tests/Feature/CommerceValidationAndCleanupTest.php:31:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
tests/Feature/CommerceValidationAndCleanupTest.php:34:        'cart_version' => 1,
tests/Feature/CommerceValidationAndCleanupTest.php:37:        'cart_id' => $cart['id'],
tests/Feature/CommerceValidationAndCleanupTest.php:70:    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
tests/Feature/CommerceValidationAndCleanupTest.php:71:    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
tests/Feature/CommerceValidationAndCleanupTest.php:74:        'cart_version' => 1,
tests/Feature/CommerceValidationAndCleanupTest.php:77:        'cart_id' => $cart['id'],
tests/Feature/CommerceValidationAndCleanupTest.php:103:test('abandoned cart cleanup does not release inventory owned by another checkout', function (): void {
tests/Feature/CommerceValidationAndCleanupTest.php:104:    $carts = app(CartService::class);
tests/Feature/CommerceValidationAndCleanupTest.php:107:    $expiredCart = $carts->create($this->store);
tests/Feature/CommerceValidationAndCleanupTest.php:108:    $carts->addLine($expiredCart, $this->variant->getKey(), 1);
tests/Feature/CommerceValidationAndCleanupTest.php:111:        'cart_id' => $expiredCart->getKey(),
tests/Feature/CommerceValidationAndCleanupTest.php:120:    $activeCart = $carts->create($this->store);
tests/Feature/CommerceValidationAndCleanupTest.php:121:    $carts->addLine($activeCart, $this->variant->getKey(), 1);
tests/Feature/CommerceValidationAndCleanupTest.php:124:        'cart_id' => $activeCart->getKey(),
tests/Feature/CommerceValidationAndCleanupTest.php:130:    DB::table('carts')->where('id', $expiredCart->getKey())->update(['updated_at' => now()->subDays(15)]);
tests/Feature/Storefront/CustomerAuthenticationTest.php:38:        ->postJson('http://shop.test/api/storefront/v1/carts')


exec

status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/app/Http/Controllers/Api/StorefrontCartController.php\n@@\n         $cart = Cart::query()\n             ->where('store_id', app('current_store')->getKey())\n+            ->where('status', 'active')\n             ->with(['lines.variant.product.media', 'lines.variant.inventory'])\n             ->findOrFail($cartId);\n         $customerId = request()->user('customer')?->getKey();\n \n         if ($customerId !== null) {\n             abort_unless((int) $cart->customer_id === (int) $customerId, 404);\n+        } else {\n+            $sessionCartIds = array_filter([\n+                request()->session()->get('cart_id'),\n+                request()->session()->get('cart_id_'.app('current_store')->getKey()),\n+            ]);\n+            abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404);\n         }\n*** Update File: /Users/wesner/Herd/shop/tests/Feature/CommerceFlowTest.php\n@@\n test('guest cart ids cannot cross tenant boundaries', function (): void {\n@@\n         ->assertNotFound();\n });\n+\n+test('guest cart endpoints only expose the active session cart', function (): void {\n+    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();\n+    $otherCart = Cart::withoutGlobalScopes()->create([\n+        'store_id' => $this->store->getKey(),\n+        'currency' => 'EUR',\n+        'cart_version' => 1,\n+        'status' => 'active',\n+    ]);\n+\n+    $this->getJson(\"http://shop.test/api/storefront/v1/carts/{$otherCart->getKey()}\")\n+        ->assertNotFound();\n+\n+    Cart::withoutGlobalScopes()->whereKey($cart['id'])->update(['status' => 'converted']);\n+\n+    $this->getJson(\"http://shop.test/api/storefront/v1/carts/{$cart['id']}\")\n+        ->assertNotFound();\n+});\n \n test('cart and checkout APIs return domain errors as unprocessable responses', function (): void {\n*** End Patch";
text(await tools.apply_patch(patch));


type: FileChange

type: update

unified_diff:
@@ -88,2 +88,3 @@
             ->where('store_id', app('current_store')->getKey())
+            ->where('status', 'active')
             ->with(['lines.variant.product.media', 'lines.variant.inventory'])
@@ -94,2 +95,8 @@
             abort_unless((int) $cart->customer_id === (int) $customerId, 404);
+        } else {
+            $sessionCartIds = array_filter([
+                request()->session()->get('cart_id'),
+                request()->session()->get('cart_id_'.app('current_store')->getKey()),
+            ]);
+            abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404);
         }

unified_diff:
@@ -137,2 +137,20 @@

+test('guest cart endpoints only expose the active session cart', function (): void {
+    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
+    $otherCart = Cart::withoutGlobalScopes()->create([
+        'store_id' => $this->store->getKey(),
+        'currency' => 'EUR',
+        'cart_version' => 1,
+        'status' => 'active',
+    ]);
+
+    $this->getJson("http://shop.test/api/storefront/v1/carts/{$otherCart->getKey()}")
+        ->assertNotFound();
+
+    Cart::withoutGlobalScopes()->whereKey($cart['id'])->update(['status' => 'converted']);
+
+    $this->getJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}")
+        ->assertNotFound();
+});
+
 test('cart and checkout APIs return domain errors as unprocessable responses', function (): void {

stdout:
Success. Updated the following files:
M /Users/wesner/Herd/shop/app/Http/Controllers/Api/StorefrontCartController.php
M /Users/wesner/Herd/shop/tests/Feature/CommerceFlowTest.php


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input:
const results = await Promise.all([
  tools.exec_command({cmd:"vendor/bin/pint --dirty --format agent",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:12000}),
  tools.exec_command({cmd:"php artisan test --compact tests/Feature/CommerceFlowTest.php tests/Feature/PlatformApiAndMediaTest.php tests/Feature/ContractBehaviorTest.php tests/Feature/SearchAnalyticsWebhookTest.php tests/Feature/OrderExportsAndDomainEventsTest.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:20000})
]);
for (const r of results) text(r.output);


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

type: unknown

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

result: pass


command: php artisan test --compact tests/Feature/CommerceFlowTest.php tests/Feature/PlatformApiAndMediaTest.php tests/Feature/ContractBehaviorTest.php tests/Feature/SearchAnalyticsWebhookTest.php tests/Feature/OrderExportsAndDomainEventsTest.php

status: failed

stdout:
  ....⨯.⨯..................
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceFlowTest > guest cart ids authorize statele…
  Expected response status code [200] but received 404.
Failed asserting that 404 is identical to 200.

  at tests/Feature/CommerceFlowTest.php:119
    115▕
    116▕     $this->flushSession();
    117▕
    118▕     $this->getJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}")
  ➜ 119▕         ->assertOk()
    120▕         ->assertJsonPath('id', $cart['id']);
    121▕ });
    122▕
    123▕ test('guest cart ids cannot cross tenant boundaries', function (): void {

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceFlowTest > guest cart endpoints only…  Error
  Class "Cart" not found

  at tests/Feature/CommerceFlowTest.php:140
    136▕ });
    137▕
    138▕ test('guest cart endpoints only expose the active session cart', function (): void {
    139▕     $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
  ➜ 140▕     $otherCart = Cart::withoutGlobalScopes()->create([
    141▕         'store_id' => $this->store->getKey(),
    142▕         'currency' => 'EUR',
    143▕         'cart_version' => 1,
    144▕         'status' => 'active',

  1   tests/Feature/CommerceFlowTest.php:140

  Tests:    2 failed, 23 passed (122 assertions)
  Duration: 3.69s


text:
Script completed
Wall time 3.9 seconds
Output:

text: {"result":"pass"}

text:
  ....⨯.⨯..................
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceFlowTest > guest cart ids authorize statele…
  Expected response status code [200] but received 404.
Failed asserting that 404 is identical to 200.

  at tests/Feature/CommerceFlowTest.php:119
    115▕
    116▕     $this->flushSession();
    117▕
    118▕     $this->getJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}")
  ➜ 119▕         ->assertOk()
    120▕         ->assertJsonPath('id', $cart['id']);
    121▕ });
    122▕
    123▕ test('guest cart ids cannot cross tenant boundaries', function (): void {

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CommerceFlowTest > guest cart endpoints only…  Error
  Class "Cart" not found

  at tests/Feature/CommerceFlowTest.php:140
    136▕ });
    137▕
    138▕ test('guest cart endpoints only expose the active session cart', function (): void {
    139▕     $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
  ➜ 140▕     $otherCart = Cart::withoutGlobalScopes()->create([
    141▕         'store_id' => $this->store->getKey(),
    142▕         'currency' => 'EUR',
    143▕         'cart_version' => 1,
    144▕         'status' => 'active',

  1   tests/Feature/CommerceFlowTest.php:140

  Tests:    2 failed, 23 passed (122 assertions)
  Duration: 3.69s


type: AgentMessage

type: Text

text: The cart hardening check caught an existing contract: guest cart IDs are intentionally tenant-scoped and remain readable after the session is flushed. I’m preserving that API behavior while still preventing access to converted/abandoned carts, and I’m correcting the regression test accordingly.

phase: commentary


type: message

role: assistant


status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/app/Http/Controllers/Api/StorefrontCartController.php\n@@\n         if ($customerId !== null) {\n             abort_unless((int) $cart->customer_id === (int) $customerId, 404);\n-        } else {\n-            $sessionCartIds = array_filter([\n-                request()->session()->get('cart_id'),\n-                request()->session()->get('cart_id_'.app('current_store')->getKey()),\n-            ]);\n-            abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404);\n         }\n*** Update File: /Users/wesner/Herd/shop/tests/Feature/CommerceFlowTest.php\n@@\n-test('guest cart endpoints only expose the active session cart', function (): void {\n+test('guest cart endpoints only expose active carts', function (): void {\n     $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();\n-    $otherCart = Cart::withoutGlobalScopes()->create([\n-        'store_id' => $this->store->getKey(),\n-        'currency' => 'EUR',\n-        'cart_version' => 1,\n-        'status' => 'active',\n-    ]);\n-\n-    $this->getJson(\"http://shop.test/api/storefront/v1/carts/{$otherCart->getKey()}\")\n-        ->assertNotFound();\n-\n     Cart::withoutGlobalScopes()->whereKey($cart['id'])->update(['status' => 'converted']);\n*** End Patch";
text(await tools.apply_patch(patch));


unified_diff:
@@ -137,14 +137,4 @@

-test('guest cart endpoints only expose the active session cart', function (): void {
+test('guest cart endpoints only expose active carts', function (): void {
     $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
-    $otherCart = Cart::withoutGlobalScopes()->create([
-        'store_id' => $this->store->getKey(),
-        'currency' => 'EUR',
-        'cart_version' => 1,
-        'status' => 'active',
-    ]);
-
-    $this->getJson("http://shop.test/api/storefront/v1/carts/{$otherCart->getKey()}")
-        ->assertNotFound();
-
     Cart::withoutGlobalScopes()->whereKey($cart['id'])->update(['status' => 'converted']);

unified_diff:
@@ -95,8 +95,2 @@
             abort_unless((int) $cart->customer_id === (int) $customerId, 404);
-        } else {
-            $sessionCartIds = array_filter([
-                request()->session()->get('cart_id'),
-                request()->session()->get('cart_id_'.app('current_store')->getKey()),
-            ]);
-            abort_unless(in_array($cart->getKey(), $sessionCartIds, true), 404);
         }


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,24p' tests/Feature/CommerceFlowTest.php; sed -n '128,154p' tests/Feature/CommerceFlowTest.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:10000,max_output_tokens:4000});
text(r.output);


command: sed -n '1,24p' tests/Feature/CommerceFlowTest.php; sed -n '128,154p' tests/Feature/CommerceFlowTest.php

CommerceFlowTest.php

stdout:
<?php

use App\Enums\PaymentStatus;
use App\Models\Discount;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\Store;
use App\Services\OrderService;
use Database\Seeders\ShopSeeder;

uses(\Illuminate\Foundation\Testing\LazilyRefreshDatabase::class);

beforeEach(function (): void {
    config(['cache.default' => 'array']);
    $this->seed(ShopSeeder::class);
    $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $this->store);
});

test('guest cart and card checkout create a paid order', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();

        'cart_version' => 1,
        'status' => 'active',
    ]));

    $this->flushSession();

    $this->getJson("http://shop.test/api/storefront/v1/carts/{$otherCart->getKey()}")
        ->assertNotFound();
});

test('guest cart endpoints only expose active carts', function (): void {
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
    Cart::withoutGlobalScopes()->whereKey($cart['id'])->update(['status' => 'converted']);

    $this->getJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}")
        ->assertNotFound();
});

test('cart and checkout APIs return domain errors as unprocessable responses', function (): void {
    $soldOutVariant = Product::query()->where('handle', 'sold-out-limited-tee')->firstOrFail()->variants()->firstOrFail();
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();

    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
        'variant_id' => $soldOutVariant->getKey(),
        'quantity' => 1,
        'cart_version' => 1,
    ])->assertUnprocessable()->assertJsonPath('code', 'insufficient_inventory');


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

use App\Enums\PaymentStatus;
use App\Models\Discount;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\Store;
use App\Services\OrderService;
use Database\Seeders\ShopSeeder;

uses(\Illuminate\Foundation\Testing\LazilyRefreshDatabase::class);

beforeEach(function (): void {
    config(['cache.default' => 'array']);
    $this->seed(ShopSeeder::class);
    $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $this->store);
});

test('guest cart and card checkout create a paid order', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();

        'cart_version' => 1,
        'status' => 'active',
    ]));

    $this->flushSession();

    $this->getJson("http://shop.test/api/storefront/v1/carts/{$otherCart->getKey()}")
        ->assertNotFound();
});

test('guest cart endpoints only expose active carts', function (): void {
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
    Cart::withoutGlobalScopes()->whereKey($cart['id'])->update(['status' => 'converted']);

    $this->getJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}")
        ->assertNotFound();
});

test('cart and checkout APIs return domain errors as unprocessable responses', function (): void {
    $soldOutVariant = Product::query()->where('handle', 'sold-out-limited-tee')->firstOrFail()->variants()->firstOrFail();
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();

    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
        'variant_id' => $soldOutVariant->getKey(),
        'quantity' => 1,
        'cart_version' => 1,
    ])->assertUnprocessable()->assertJsonPath('code', 'insufficient_inventory');


exec

status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/tests/Feature/CommerceFlowTest.php\n@@\n use App\\Enums\\PaymentStatus;\n+use App\\Models\\Cart;\n use App\\Models\\Discount;\n*** End Patch";
text(await tools.apply_patch(patch));


unified_diff:
@@ -3,2 +3,3 @@
 use App\Enums\PaymentStatus;
+use App\Models\Cart;
 use App\Models\Discount;

stdout:
Success. Updated the following files:
M /Users/wesner/Herd/shop/tests/Feature/CommerceFlowTest.php


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input:
const r = await tools.exec_command({cmd:"vendor/bin/pint --dirty --format agent && php artisan test --compact tests/Feature/CommerceFlowTest.php tests/Feature/PlatformApiAndMediaTest.php tests/Feature/ContractBehaviorTest.php tests/Feature/SearchAnalyticsWebhookTest.php tests/Feature/OrderExportsAndDomainEventsTest.php",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:22000});
text(r.output);


command: vendor/bin/pint --dirty --format agent && php artisan test --compact tests/Feature/CommerceFlowTest.php tests/Feature/PlatformApiAndMediaTest.php tests/Feature/ContractBehaviorTest.php tests/Feature/SearchAnalyticsWebhookTest.php tests/Feature/OrderExportsAndDomainEventsTest.php


text:
Script completed
Wall time 4.1 seconds
Output:

text:
{"result":"pass"}
  .........................

  Tests:    25 passed (124 assertions)
  Duration: 3.60s


text: The focused commerce/API suite is green: 25 tests and 124 assertions. The cart contract now rejects inactive carts without breaking the existing stateless guest-cart behavior. I’m moving to the full suite and production-style build checks next.


status: completed

input:
const r = await tools.exec_command({cmd:"php artisan test --compact",workdir:"/Users/wesner/Herd/shop",yield_time_ms:30000,max_output_tokens:30000});
text(JSON.stringify(r));


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

cmd: php artisan test --compact

stdout:
  ..................................⨯⨯⨯⨯⨯.....................................
  ..................
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\PasswordResetTest > reset password link can be…
  The expected [Illuminate\Auth\Notifications\ResetPassword] notification was not sent.
Failed asserting that false is true.

  at vendor/laravel/framework/src/Illuminate/Support/Testing/Fakes/NotificationFake.php:89
     85▕         if (is_numeric($callback)) {
     86▕             return $this->assertSentToTimes($notifiable, $notification, $callback);
     87▕         }
     88▕
  ➜  89▕         PHPUnit::assertTrue(
     90▕             $this->sent($notifiable, $notification, $callback)->count() > 0,
     91▕             "The expected [{$notification}] notification was not sent."
     92▕         );
     93▕     }

      +2 vendor frames
  3   tests/Feature/Auth/PasswordResetTest.php:22

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\PasswordResetTest > reset password screen can…
  The expected [Illuminate\Auth\Notifications\ResetPassword] notification was not sent.
Failed asserting that false is true.

  at vendor/laravel/framework/src/Illuminate/Support/Testing/Fakes/NotificationFake.php:89
     85▕         if (is_numeric($callback)) {
     86▕             return $this->assertSentToTimes($notifiable, $notification, $callback);
     87▕         }
     88▕
  ➜  89▕         PHPUnit::assertTrue(
     90▕             $this->sent($notifiable, $notification, $callback)->count() > 0,
     91▕             "The expected [{$notification}] notification was not sent."
     92▕         );
     93▕     }

      +2 vendor frames
  3   tests/Feature/Auth/PasswordResetTest.php:32

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\PasswordResetTest > password can be reset with…
  The expected [Illuminate\Auth\Notifications\ResetPassword] notification was not sent.
Failed asserting that false is true.

  at vendor/laravel/framework/src/Illuminate/Support/Testing/Fakes/NotificationFake.php:89
     85▕         if (is_numeric($callback)) {
     86▕             return $this->assertSentToTimes($notifiable, $notification, $callback);
     87▕         }
     88▕
  ➜  89▕         PHPUnit::assertTrue(
     90▕             $this->sent($notifiable, $notification, $callback)->count() > 0,
     91▕             "The expected [{$notification}] notification was not sent."
     92▕         );
     93▕     }

      +2 vendor frames
  3   tests/Feature/Auth/PasswordResetTest.php:47

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\RegistrationTest > reg…  RouteNotFoundException
  Route [register] not defined.

  at vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php:528
    524▕             ! is_null($url = call_user_func($this->missingNamedRouteResolver, $name, $parameters, $absolute))) {
    525▕             return $url;
    526▕         }
    527▕
  ➜ 528▕         throw new RouteNotFoundException("Route [{$name}] not defined.");
    529▕     }
    530▕
    531▕     /**
    532▕      * Get the URL for a given route instance.

      +2 vendor frames
  3   tests/Feature/Auth/RegistrationTest.php:6

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\RegistrationTest > new…  RouteNotFoundException
  Route [register.store] not defined.

  at vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php:528
    524▕             ! is_null($url = call_user_func($this->missingNamedRouteResolver, $name, $parameters, $absolute))) {
    525▕             return $url;
    526▕         }
    527▕
  ➜ 528▕         throw new RouteNotFoundException("Route [{$name}] not defined.");
    529▕     }
    530▕
    531▕     /**
    532▕      * Get the URL for a given route instance.

      +2 vendor frames
  3   tests/Feature/Auth/RegistrationTest.php:12

  Tests:    5 failed, 89 passed (316 assertions)
  Duration: 7.60s