type: CommandExecution

command: sed -n '1,280p' app/Http/Controllers/Api/StorefrontCheckoutController.php && sed -n '1,240p' app/Http/Requests/SetCheckoutAddressRequest.php && sed -n '1,180p' app/Http/Requests/ApplyDiscountRequest.php && git status --short

type: unknown

status: completed

stdout:
<?php

namespace App\Http\Controllers\Api;

use App\Enums\PaymentMethod;
use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidDiscountException;
use App\Http\Controllers\Controller;
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()->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(Request $request, int $checkoutId): JsonResponse
    {
        $checkout = $this->checkout($checkoutId);
        $requiresShipping = $checkout->cart->lines->contains(fn ($line): bool => (bool) $line->variant?->requires_shipping);
        $useShippingAsBilling = $request->boolean('use_shipping_as_billing', true);
        $rules = [
            'use_shipping_as_billing' => ['sometimes', 'boolean'],
            ...$this->addressValidationRules('shipping_address', $requiresShipping),
        ];

        if (! $useShippingAsBilling) {
            $rules = [...$rules, ...$this->addressValidationRules('billing_address', $requiresShipping)];
        }

        $data = $request->validate($rules);
        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'], 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(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['code' => ['required', 'string', 'max:64']]);
        $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 (\LogicException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422);
        }

        if ($order === null) {
            return response()->json(['message' => 'Payment failed.', 'code' => 'payment_failed'], 422);
        }

        return response()->json(['order' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'total_amount' => $order->total_amount], 'message' => $order->financial_status->value === 'pending' ? 'Bank transfer instructions generated.' : 'Order confirmed.']);
    }

    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, array<int, string>>
     */
    private function addressValidationRules(string $key, bool $required): array
    {
        $presence = $required ? 'required' : 'sometimes';
        $root = $required ? 'required' : 'nullable';

        return [
            $key => [$root, 'array'],
            "{$key}.first_name" => [$presence, 'string', 'max:255'],
            "{$key}.last_name" => [$presence, 'string', 'max:255'],
            "{$key}.address1" => [$presence, 'string', 'max:500'],
            "{$key}.address2" => ['sometimes', 'nullable', 'string', 'max:500'],
            "{$key}.company" => ['sometimes', 'nullable', 'string', 'max:255'],
            "{$key}.city" => [$presence, 'string', 'max:255'],
            "{$key}.province" => ['sometimes', 'nullable', 'string', 'max:255'],
            "{$key}.province_code" => ['sometimes', 'nullable', 'string', 'max:10'],
            "{$key}.country" => [$presence, 'string', 'max:255'],
            "{$key}.country_code" => [$presence, 'string', 'regex:/^[A-Z]{2}$/'],
            "{$key}.postal_code" => [$presence, 'string', 'max:20'],
            "{$key}.phone" => ['sometimes', 'nullable', 'string', 'max:50'],
        ];
    }

    /** @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];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'shipping_address' => ['required', 'array'],
            'shipping_address.first_name' => ['required', 'string', 'max:100'],
            'shipping_address.last_name' => ['required', 'string', 'max:100'],
            'shipping_address.address1' => ['required', 'string', 'max:255'],
            'shipping_address.city' => ['required', 'string', 'max:100'],
            'shipping_address.country_code' => ['required', 'string', 'size:2'],
            'shipping_address.province_code' => ['nullable', 'string', 'max:10'],
            'shipping_address.postal_code' => ['required', 'string', 'max:20'],
            'shipping_address.phone' => ['nullable', 'string', 'max:30'],
            'billing_address' => ['nullable', 'array'],
            'use_shipping_as_billing' => ['nullable', 'boolean'],
        ];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['code' => ['required', 'string', 'max:64']];
    }
}
 M .env.example
 M app/Http/Controllers/Api/AdminController.php
 M app/Http/Controllers/Api/StorefrontAnalyticsController.php
 M app/Http/Controllers/Api/StorefrontCartController.php
 M app/Http/Controllers/Api/StorefrontCheckoutController.php
 M app/Http/Middleware/ResolveStore.php
 M app/Jobs/CleanupAbandonedCarts.php
 M app/Jobs/DeliverWebhook.php
 M app/Livewire/Admin/Analytics/Index.php
 M app/Livewire/Admin/Apps/Index.php
 M app/Livewire/Admin/Auth/Login.php
 M app/Livewire/Admin/Collections/Create.php
 M app/Livewire/Admin/Collections/Edit.php
 M app/Livewire/Admin/Collections/Index.php
 M app/Livewire/Admin/Customers/Index.php
 M app/Livewire/Admin/Customers/Show.php
 M app/Livewire/Admin/Dashboard.php
 M app/Livewire/Admin/Developers/Index.php
 M app/Livewire/Admin/Discounts/Form.php
 M app/Livewire/Admin/Discounts/Index.php
 M app/Livewire/Admin/Inventory/Index.php
 M app/Livewire/Admin/Navigation/Index.php
 M app/Livewire/Admin/Orders/Index.php
 M app/Livewire/Admin/Orders/Show.php
 M app/Livewire/Admin/Pages/Create.php
 M app/Livewire/Admin/Pages/Edit.php
 M app/Livewire/Admin/Pages/Index.php
 M app/Livewire/Admin/Products/Index.php
 M app/Livewire/Admin/Search/Settings.php
 M app/Livewire/Admin/Settings/General.php
 M app/Livewire/Admin/Settings/Shipping.php
 M app/Livewire/Admin/Settings/Taxes.php
 M app/Livewire/Admin/Themes/Editor.php
 M app/Livewire/Admin/Themes/Index.php
 M app/Livewire/Storefront/Account/Auth/Login.php
 M app/Livewire/Storefront/Account/Auth/Register.php
 M app/Livewire/Storefront/Cart/Show.php
 M app/Livewire/Storefront/Checkout/Confirmation.php
 M app/Livewire/Storefront/Checkout/Show.php
 M app/Livewire/Storefront/Products/Show.php
 M app/Models/Collection.php
 M app/Models/Customer.php
 M app/Models/NavigationMenu.php
 M app/Models/Refund.php
 M app/Models/SearchQuery.php
 M app/Models/Theme.php
 M app/Models/ThemeSetting.php
 M app/Models/User.php
 M app/Models/WebhookSubscription.php
 M app/Providers/AppServiceProvider.php
 M app/Services/AnalyticsService.php
 M app/Services/CartService.php
 M app/Services/FulfillmentService.php
 M app/Services/OrderService.php
 M app/Services/PaymentService.php
 M app/Services/ProductService.php
 M app/Services/RefundService.php
 M app/Services/ShippingCalculator.php
 M app/Services/TaxCalculator.php
 M app/Services/VariantMatrixService.php
 M boost.json
 M bootstrap/app.php
 M config/auth.php
 M config/logging.php
 M config/session.php
 M config/shop.php
 M database/factories/CartFactory.php
 M database/factories/CollectionFactory.php
 M database/factories/CustomerFactory.php
 M database/factories/DiscountFactory.php
 M database/factories/InventoryItemFactory.php
 M database/factories/OrderFactory.php
 M database/factories/ProductFactory.php
 M database/factories/ProductVariantFactory.php
 M database/factories/StoreDomainFactory.php
 M database/factories/StoreFactory.php
 M database/factories/UserFactory.php
 M database/seeders/DatabaseSeeder.php
 M database/seeders/OrganizationSeeder.php
 M database/seeders/ShopSeeder.php
 M database/seeders/StoreDomainSeeder.php
 M database/seeders/StoreSeeder.php
 M database/seeders/StoreSettingsSeeder.php
 M resources/views/layouts/admin.blade.php
 M resources/views/layouts/auth.blade.php
 M resources/views/layouts/storefront.blade.php
 M resources/views/livewire/admin/analytics/index.blade.php
 M resources/views/livewire/admin/apps/index.blade.php
 M resources/views/livewire/admin/auth/login.blade.php
 M resources/views/livewire/admin/collections/create.blade.php
 M resources/views/livewire/admin/collections/edit.blade.php
 M resources/views/livewire/admin/collections/index.blade.php
 M resources/views/livewire/admin/customers/index.blade.php
 M resources/views/livewire/admin/customers/show.blade.php
 M resources/views/livewire/admin/dashboard.blade.php
 M resources/views/livewire/admin/developers/index.blade.php
 M resources/views/livewire/admin/discounts/form.blade.php
 M resources/views/livewire/admin/discounts/index.blade.php
 M resources/views/livewire/admin/inventory/index.blade.php
 M resources/views/livewire/admin/navigation/index.blade.php
 M resources/views/livewire/admin/orders/index.blade.php
 M resources/views/livewire/admin/orders/show.blade.php
 M resources/views/livewire/admin/pages/create.blade.php
 M resources/views/livewire/admin/pages/edit.blade.php
 M resources/views/livewire/admin/pages/index.blade.php
 M resources/views/livewire/admin/products/index.blade.php
 M resources/views/livewire/admin/search/settings.blade.php
 M resources/views/livewire/admin/settings/general.blade.php
 M resources/views/livewire/admin/settings/shipping.blade.php
 M resources/views/livewire/admin/settings/taxes.blade.php
 M resources/views/livewire/admin/themes/editor.blade.php
 M resources/views/livewire/admin/themes/index.blade.php
 M resources/views/livewire/storefront/account/auth/login.blade.php
 M resources/views/livewire/storefront/account/auth/register.blade.php
 M resources/views/livewire/storefront/cart/show.blade.php
 M resources/views/livewire/storefront/checkout/confirmation.blade.php
 M resources/views/livewire/storefront/checkout/show.blade.php
 M resources/views/livewire/storefront/products/show.blade.php
 M routes/api.php
 M routes/web.php
 M specs/progress.md
 M tests/Feature/AdminApiTest.php
 M tests/Feature/CommerceFlowTest.php
 M tests/Feature/SearchAnalyticsWebhookTest.php
 M tests/Unit/DomainServicesTest.php
?? .playwright-mcp/
?? app/Auth/StoreScopedPasswordBrokerManager.php
?? app/Auth/StoreScopedTokenRepository.php
?? app/Contracts/TaxProvider.php
?? app/Http/Controllers/Api/StorefrontOrderController.php
?? app/Http/Controllers/Api/StorefrontSearchController.php
?? app/Http/Middleware/EnsureApiAbility.php
?? app/Http/Requests/
?? app/Livewire/Admin/Collections/Form.php
?? app/Livewire/Admin/Pages/Form.php
?? app/Livewire/Admin/Settings/Domains.php
?? app/Livewire/Storefront/CartDrawer.php
?? app/Services/AuditLogger.php
?? app/Services/Tax/
?? app/ValueObjects/TaxCalculationRequest.php
?? config/cors.php
?? config/sanctum.php
?? database/factories/AnalyticsDailyFactory.php
?? database/factories/AnalyticsEventFactory.php
?? database/factories/CartLineFactory.php
?? database/factories/CheckoutFactory.php
?? database/factories/CustomerAddressFactory.php
?? database/factories/FulfillmentFactory.php
?? database/factories/FulfillmentLineFactory.php
?? database/factories/NavigationItemFactory.php
?? database/factories/NavigationMenuFactory.php
?? database/factories/OrderLineFactory.php
?? database/factories/PageFactory.php
?? database/factories/PaymentFactory.php
?? database/factories/ProductMediaFactory.php
?? database/factories/ProductOptionFactory.php
?? database/factories/ProductOptionValueFactory.php
?? database/factories/RefundFactory.php
?? database/factories/SearchSettingFactory.php
?? database/factories/ShippingRateFactory.php
?? database/factories/ShippingZoneFactory.php
?? database/factories/TaxSettingsFactory.php
?? database/factories/ThemeFactory.php
?? database/factories/ThemeSettingFactory.php
?? database/migrations/2026_08_20_224526_add_line_allocations_to_refunds_table.php
?? database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php
?? database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php
?? database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php
?? database/migrations/2026_08_20_235245_remove_legacy_settings_from_themes_table.php
?? database/seeders/AnalyticsSeeder.php
?? database/seeders/CollectionSeeder.php
?? database/seeders/CustomerSeeder.php
?? database/seeders/DiscountSeeder.php
?? database/seeders/NavigationSeeder.php
?? database/seeders/OrderSeeder.php
?? database/seeders/PageSeeder.php
?? database/seeders/ProductSeeder.php
?? database/seeders/SearchSettingsSeeder.php
?? database/seeders/ShippingSeeder.php
?? database/seeders/StoreUserSeeder.php
?? database/seeders/TaxSettingsSeeder.php
?? database/seeders/ThemeSeeder.php
?? database/seeders/UserSeeder.php
?? resources/views/errors/
?? resources/views/livewire/admin/collections/form.blade.php
?? resources/views/livewire/admin/pages/form.blade.php
?? resources/views/livewire/admin/settings/domains.blade.php
?? resources/views/livewire/storefront/cart-drawer.blade.php
?? tests/Feature/ApiTokenTest.php
?? tests/Feature/CommerceValidationAndCleanupTest.php
?? tests/Feature/ContractBehaviorTest.php
?? tests/Feature/SeedDataTest.php


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Http\Controllers\Api;

use App\Enums\PaymentMethod;
use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidDiscountException;
use App\Http\Controllers\Controller;
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()->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(Request $request, int $checkoutId): JsonResponse
    {
        $checkout = $this->checkout($checkoutId);
        $requiresShipping = $checkout->cart->lines->contains(fn ($line): bool => (bool) $line->variant?->requires_shipping);
        $useShippingAsBilling = $request->boolean('use_shipping_as_billing', true);
        $rules = [
            'use_shipping_as_billing' => ['sometimes', 'boolean'],
            ...$this->addressValidationRules('shipping_address', $requiresShipping),
        ];

        if (! $useShippingAsBilling) {
            $rules = [...$rules, ...$this->addressValidationRules('billing_address', $requiresShipping)];
        }

        $data = $request->validate($rules);
        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'], 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(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['code' => ['required', 'string', 'max:64']]);
        $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 (\LogicException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'checkout_state_invalid'], 422);
        }

        if ($order === null) {
            return response()->json(['message' => 'Payment failed.', 'code' => 'payment_failed'], 422);
        }

        return response()->json(['order' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'total_amount' => $order->total_amount], 'message' => $order->financial_status->value === 'pending' ? 'Bank transfer instructions generated.' : 'Order confirmed.']);
    }

    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, array<int, string>>
     */
    private function addressValidationRules(string $key, bool $required): array
    {
        $presence = $required ? 'required' : 'sometimes';
        $root = $required ? 'required' : 'nullable';

        return [
            $key => [$root, 'array'],
            "{$key}.first_name" => [$presence, 'string', 'max:255'],
            "{$key}.last_name" => [$presence, 'string', 'max:255'],
            "{$key}.address1" => [$presence, 'string', 'max:500'],
            "{$key}.address2" => ['sometimes', 'nullable', 'string', 'max:500'],
            "{$key}.company" => ['sometimes', 'nullable', 'string', 'max:255'],
            "{$key}.city" => [$presence, 'string', 'max:255'],
            "{$key}.province" => ['sometimes', 'nullable', 'string', 'max:255'],
            "{$key}.province_code" => ['sometimes', 'nullable', 'string', 'max:10'],
            "{$key}.country" => [$presence, 'string', 'max:255'],
            "{$key}.country_code" => [$presence, 'string', 'regex:/^[A-Z]{2}$/'],
            "{$key}.postal_code" => [$presence, 'string', 'max:20'],
            "{$key}.phone" => ['sometimes', 'nullable', 'string', 'max:50'],
        ];
    }

    /** @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];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'shipping_address' => ['required', 'array'],
            'shipping_address.first_name' => ['required', 'string', 'max:100'],
            'shipping_address.last_name' => ['required', 'string', 'max:100'],
            'shipping_address.address1' => ['required', 'string', 'max:255'],
            'shipping_address.city' => ['required', 'string', 'max:100'],
            'shipping_address.country_code' => ['required', 'string', 'size:2'],
            'shipping_address.province_code' => ['nullable', 'string', 'max:10'],
            'shipping_address.postal_code' => ['required', 'string', 'max:20'],
            'shipping_address.phone' => ['nullable', 'string', 'max:30'],
            'billing_address' => ['nullable', 'array'],
            'use_shipping_as_billing' => ['nullable', 'boolean'],
        ];
    }
}
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return ['code' => ['required', 'string', 'max:64']];
    }
}
 M .env.example
 M app/Http/Controllers/Api/AdminController.php
 M app/Http/Controllers/Api/StorefrontAnalyticsController.php
 M app/Http/Controllers/Api/StorefrontCartController.php
 M app/Http/Controllers/Api/StorefrontCheckoutController.php
 M app/Http/Middleware/ResolveStore.php
 M app/Jobs/CleanupAbandonedCarts.php
 M app/Jobs/DeliverWebhook.php
 M app/Livewire/Admin/Analytics/Index.php
 M app/Livewire/Admin/Apps/Index.php
 M app/Livewire/Admin/Auth/Login.php
 M app/Livewire/Admin/Collections/Create.php
 M app/Livewire/Admin/Collections/Edit.php
 M app/Livewire/Admin/Collections/Index.php
 M app/Livewire/Admin/Customers/Index.php
 M app/Livewire/Admin/Customers/Show.php
 M app/Livewire/Admin/Dashboard.php
 M app/Livewire/Admin/Developers/Index.php
 M app/Livewire/Admin/Discounts/Form.php
 M app/Livewire/Admin/Discounts/Index.php
 M app/Livewire/Admin/Inventory/Index.php
 M app/Livewire/Admin/Navigation/Index.php
 M app/Livewire/Admin/Orders/Index.php
 M app/Livewire/Admin/Orders/Show.php
 M app/Livewire/Admin/Pages/Create.php
 M app/Livewire/Admin/Pages/Edit.php
 M app/Livewire/Admin/Pages/Index.php
 M app/Livewire/Admin/Products/Index.php
 M app/Livewire/Admin/Search/Settings.php
 M app/Livewire/Admin/Settings/General.php
 M app/Livewire/Admin/Settings/Shipping.php
 M app/Livewire/Admin/Settings/Taxes.php
 M app/Livewire/Admin/Themes/Editor.php
 M app/Livewire/Admin/Themes/Index.php
 M app/Livewire/Storefront/Account/Auth/Login.php
 M app/Livewire/Storefront/Account/Auth/Register.php
 M app/Livewire/Storefront/Cart/Show.php
 M app/Livewire/Storefront/Checkout/Confirmation.php
 M app/Livewire/Storefront/Checkout/Show.php
 M app/Livewire/Storefront/Products/Show.php
 M app/Models/Collection.php
 M app/Models/Customer.php
 M app/Models/NavigationMenu.php
 M app/Models/Refund.php
 M app/Models/SearchQuery.php
 M app/Models/Theme.php
 M app/Models/ThemeSetting.php
 M app/Models/User.php
 M app/Models/WebhookSubscription.php
 M app/Providers/AppServiceProvider.php
 M app/Services/AnalyticsService.php
 M app/Services/CartService.php
 M app/Services/FulfillmentService.php
 M app/Services/OrderService.php
 M app/Services/PaymentService.php
 M app/Services/ProductService.php
 M app/Services/RefundService.php
 M app/Services/ShippingCalculator.php
 M app/Services/TaxCalculator.php
 M app/Services/VariantMatrixService.php
 M boost.json
 M bootstrap/app.php
 M config/auth.php
 M config/logging.php
 M config/session.php
 M config/shop.php
 M database/factories/CartFactory.php
 M database/factories/CollectionFactory.php
 M database/factories/CustomerFactory.php
 M database/factories/DiscountFactory.php
 M database/factories/InventoryItemFactory.php
 M database/factories/OrderFactory.php
 M database/factories/ProductFactory.php
 M database/factories/ProductVariantFactory.php
 M database/factories/StoreDomainFactory.php
 M database/factories/StoreFactory.php
 M database/factories/UserFactory.php
 M database/seeders/DatabaseSeeder.php
 M database/seeders/OrganizationSeeder.php
 M database/seeders/ShopSeeder.php
 M database/seeders/StoreDomainSeeder.php
 M database/seeders/StoreSeeder.php
 M database/seeders/StoreSettingsSeeder.php
 M resources/views/layouts/admin.blade.php
 M resources/views/layouts/auth.blade.php
 M resources/views/layouts/storefront.blade.php
 M resources/views/livewire/admin/analytics/index.blade.php
 M resources/views/livewire/admin/apps/index.blade.php
 M resources/views/livewire/admin/auth/login.blade.php
 M resources/views/livewire/admin/collections/create.blade.php
 M resources/views/livewire/admin/collections/edit.blade.php
 M resources/views/livewire/admin/collections/index.blade.php
 M resources/views/livewire/admin/customers/index.blade.php
 M resources/views/livewire/admin/customers/show.blade.php
 M resources/views/livewire/admin/dashboard.blade.php
 M resources/views/livewire/admin/developers/index.blade.php
 M resources/views/livewire/admin/discounts/form.blade.php
 M resources/views/livewire/admin/discounts/index.blade.php
 M resources/views/livewire/admin/inventory/index.blade.php
 M resources/views/livewire/admin/navigation/index.blade.php
 M resources/views/livewire/admin/orders/index.blade.php
 M resources/views/livewire/admin/orders/show.blade.php
 M resources/views/livewire/admin/pages/create.blade.php
 M resources/views/livewire/admin/pages/edit.blade.php
 M resources/views/livewire/admin/pages/index.blade.php
 M resources/views/livewire/admin/products/index.blade.php
 M resources/views/livewire/admin/search/settings.blade.php
 M resources/views/livewire/admin/settings/general.blade.php
 M resources/views/livewire/admin/settings/shipping.blade.php
 M resources/views/livewire/admin/settings/taxes.blade.php
 M resources/views/livewire/admin/themes/editor.blade.php
 M resources/views/livewire/admin/themes/index.blade.php
 M resources/views/livewire/storefront/account/auth/login.blade.php
 M resources/views/livewire/storefront/account/auth/register.blade.php
 M resources/views/livewire/storefront/cart/show.blade.php
 M resources/views/livewire/storefront/checkout/confirmation.blade.php
 M resources/views/livewire/storefront/checkout/show.blade.php
 M resources/views/livewire/storefront/products/show.blade.php
 M routes/api.php
 M routes/web.php
 M specs/progress.md
 M tests/Feature/AdminApiTest.php
 M tests/Feature/CommerceFlowTest.php
 M tests/Feature/SearchAnalyticsWebhookTest.php
 M tests/Unit/DomainServicesTest.php
?? .playwright-mcp/
?? app/Auth/StoreScopedPasswordBrokerManager.php
?? app/Auth/StoreScopedTokenRepository.php
?? app/Contracts/TaxProvider.php
?? app/Http/Controllers/Api/StorefrontOrderController.php
?? app/Http/Controllers/Api/StorefrontSearchController.php
?? app/Http/Middleware/EnsureApiAbility.php
?? app/Http/Requests/
?? app/Livewire/Admin/Collections/Form.php
?? app/Livewire/Admin/Pages/Form.php
?? app/Livewire/Admin/Settings/Domains.php
?? app/Livewire/Storefront/CartDrawer.php
?? app/Services/AuditLogger.php
?? app/Services/Tax/
?? app/ValueObjects/TaxCalculationRequest.php
?? config/cors.php
?? config/sanctum.php
?? database/factories/AnalyticsDailyFactory.php
?? database/factories/AnalyticsEventFactory.php
?? database/factories/CartLineFactory.php
?? database/factories/CheckoutFactory.php
?? database/factories/CustomerAddressFactory.php
?? database/factories/FulfillmentFactory.php
?? database/factories/FulfillmentLineFactory.php
?? database/factories/NavigationItemFactory.php
?? database/factories/NavigationMenuFactory.php
?? database/factories/OrderLineFactory.php
?? database/factories/PageFactory.php
?? database/factories/PaymentFactory.php
?? database/factories/ProductMediaFactory.php
?? database/factories/ProductOptionFactory.php
?? database/factories/ProductOptionValueFactory.php
?? database/factories/RefundFactory.php
?? database/factories/SearchSettingFactory.php
?? database/factories/ShippingRateFactory.php
?? database/factories/ShippingZoneFactory.php
?? database/factories/TaxSettingsFactory.php
?? database/factories/ThemeFactory.php
?? database/factories/ThemeSettingFactory.php
?? database/migrations/2026_08_20_224526_add_line_allocations_to_refunds_table.php
?? database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php
?? database/migrations/2026_08_20_231709_scope_customer_password_reset_tokens_by_store.php
?? database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php
?? database/migrations/2026_08_20_235245_remove_legacy_settings_from_themes_table.php
?? database/seeders/AnalyticsSeeder.php
?? database/seeders/CollectionSeeder.php
?? database/seeders/CustomerSeeder.php
?? database/seeders/DiscountSeeder.php
?? database/seeders/NavigationSeeder.php
?? database/seeders/OrderSeeder.php
?? database/seeders/PageSeeder.php
?? database/seeders/ProductSeeder.php
?? database/seeders/SearchSettingsSeeder.php
?? database/seeders/ShippingSeeder.php
?? database/seeders/StoreUserSeeder.php
?? database/seeders/TaxSettingsSeeder.php
?? database/seeders/ThemeSeeder.php
?? database/seeders/UserSeeder.php
?? resources/views/errors/
?? resources/views/livewire/admin/collections/form.blade.php
?? resources/views/livewire/admin/pages/form.blade.php
?? resources/views/livewire/admin/settings/domains.blade.php
?? resources/views/livewire/storefront/cart-drawer.blade.php
?? tests/Feature/ApiTokenTest.php
?? tests/Feature/CommerceValidationAndCleanupTest.php
?? tests/Feature/ContractBehaviorTest.php
?? tests/Feature/SeedDataTest.php


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "rg -n \"SetCheckoutAddressRequest|ApplyDiscountRequest|address\\(|applyDiscount|FormRequest|\\$request->validate\" app/Http/Controllers app/Http/Requests tests/Feature | head -240 && sed -n '1,220p' app/Models/Checkout.php && sed -n '1,260p' app/Services/CheckoutService.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 10000,
  max_output_tokens: 30000
});
text(r.output);


command: rg -n "SetCheckoutAddressRequest|ApplyDiscountRequest|address\(|applyDiscount|FormRequest|\$request->validate" app/Http/Controllers app/Http/Requests tests/Feature | head -240 && sed -n '1,220p' app/Models/Checkout.php && sed -n '1,260p' app/Services/CheckoutService.php

type: search

cmd: rg -n "SetCheckoutAddressRequest|ApplyDiscountRequest|address\\(|applyDiscount|FormRequest|\\"'$request->validate' app/Http/Controllers app/Http/Requests tests/Feature

query: SetCheckoutAddressRequest|ApplyDiscountRequest|address\(|applyDiscount|FormRequest|\$request->validate

path: Controllers

Checkout.php

type: read

cmd: sed -n '1,220p' app/Models/Checkout.php

CheckoutService.php

stdout:
app/Http/Requests/StoreShippingRateRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreShippingRateRequest.php:8:class StoreShippingRateRequest extends FormRequest
app/Http/Controllers/Api/StorefrontCheckoutController.php:51:    public function address(Request $request, int $checkoutId): JsonResponse
app/Http/Controllers/Api/StorefrontCheckoutController.php:87:    public function applyDiscount(Request $request, int $checkoutId): JsonResponse
app/Http/Requests/StoreProductRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreProductRequest.php:9:class StoreProductRequest extends FormRequest
app/Http/Requests/UpdateShippingZoneRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateShippingZoneRequest.php:8:class UpdateShippingZoneRequest extends FormRequest
app/Http/Requests/UpdateDiscountRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateDiscountRequest.php:10:class UpdateDiscountRequest extends FormRequest
app/Http/Requests/StoreShippingZoneRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreShippingZoneRequest.php:8:class StoreShippingZoneRequest extends FormRequest
app/Http/Requests/CreateFulfillmentRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/CreateFulfillmentRequest.php:9:class CreateFulfillmentRequest extends FormRequest
app/Http/Requests/StoreDiscountRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreDiscountRequest.php:10:class StoreDiscountRequest extends FormRequest
app/Http/Requests/UpdateStoreSettingsRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateStoreSettingsRequest.php:8:class UpdateStoreSettingsRequest extends FormRequest
app/Http/Requests/UpdateTaxSettingsRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateTaxSettingsRequest.php:8:class UpdateTaxSettingsRequest extends FormRequest
app/Http/Requests/RegisterCustomerRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/RegisterCustomerRequest.php:7:class RegisterCustomerRequest extends FormRequest
app/Http/Requests/ApplyDiscountRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/ApplyDiscountRequest.php:7:class ApplyDiscountRequest extends FormRequest
app/Http/Requests/UpdateCollectionRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateCollectionRequest.php:9:class UpdateCollectionRequest extends FormRequest
app/Http/Requests/LoginRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/LoginRequest.php:7:class LoginRequest extends FormRequest
app/Http/Requests/StorePageRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StorePageRequest.php:8:class StorePageRequest extends FormRequest
app/Http/Requests/UpdateThemeSettingsRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateThemeSettingsRequest.php:8:class UpdateThemeSettingsRequest extends FormRequest
app/Http/Requests/StoreCollectionRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreCollectionRequest.php:9:class StoreCollectionRequest extends FormRequest
app/Http/Requests/InviteStaffRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/InviteStaffRequest.php:8:class InviteStaffRequest extends FormRequest
app/Http/Requests/SetCheckoutAddressRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/SetCheckoutAddressRequest.php:7:class SetCheckoutAddressRequest extends FormRequest
app/Http/Requests/CreateRefundRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/CreateRefundRequest.php:9:class CreateRefundRequest extends FormRequest
app/Http/Requests/StoreThemeRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreThemeRequest.php:8:class StoreThemeRequest extends FormRequest
app/Http/Requests/UpdatePageRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdatePageRequest.php:8:class UpdatePageRequest extends FormRequest
app/Http/Requests/UpdateProductRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateProductRequest.php:9:class UpdateProductRequest extends FormRequest
<?php

namespace App\Models;

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

class Checkout extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'cart_id', 'customer_id', 'status', 'email', 'payment_method', 'shipping_address_json', 'billing_address_json', 'shipping_rate_id', 'shipping_method_id', 'discount_code', 'totals_json', 'tax_provider_snapshot_json', 'expires_at'];

    protected function casts(): array
    {
        return ['status' => CheckoutStatus::class, 'shipping_address_json' => 'array', 'billing_address_json' => 'array', 'totals_json' => 'array', 'tax_provider_snapshot_json' => 'array', 'expires_at' => 'datetime'];
    }

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

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

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

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

    public function isExpired(): bool
    {
        return $this->expires_at !== null && $this->expires_at->isPast();
    }
}
<?php

namespace App\Services;

use App\Enums\CheckoutStatus;
use App\Models\Cart;
use App\Models\Checkout;
use App\Models\Customer;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;

class CheckoutService
{
    public function __construct(private readonly PricingEngine $pricing, private readonly ShippingCalculator $shipping, private readonly InventoryService $inventory) {}

    public function create(Cart $cart, string $email, ?Customer $customer = null): Checkout
    {
        $cart->load('lines');

        if ($cart->lines->isEmpty()) {
            throw new \InvalidArgumentException('Cannot checkout an empty cart.');
        }

        Validator::make(['email' => $email], ['email' => ['required', 'email']])->validate();

        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)]);
    }

    public function setAddress(Checkout $checkout, array $address, ?array $billing = null, bool $useShippingAsBilling = true): Checkout
    {
        if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) {
            throw new \LogicException('This checkout can no longer be changed.');
        }

        Validator::make($address, ['first_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'], 'address1' => ['required', 'string', 'max:500'], 'city' => ['required', 'string', 'max:255'], 'country_code' => ['required', 'string', 'size:2'], 'postal_code' => ['required', 'string', 'max:20']])->validate();
        $checkout->update(['shipping_address_json' => $address, 'billing_address_json' => $useShippingAsBilling ? $address : $billing, 'status' => CheckoutStatus::Addressed]);
        $this->pricing->calculate($checkout->refresh());

        return $checkout->refresh();
    }

    public function setShippingMethod(Checkout $checkout, int $rateId): Checkout
    {
        $checkout->loadMissing('cart.lines.variant');

        if (! $this->requiresShipping($checkout)) {
            $checkout->update(['shipping_rate_id' => null, 'shipping_method_id' => null, 'status' => CheckoutStatus::ShippingSelected]);
            $this->pricing->calculate($checkout->refresh());

            return $checkout->refresh();
        }

        if ($checkout->shipping_address_json === null) {
            throw new \LogicException('An address is required before selecting shipping.');
        }

        $rate = $checkout->shipping_address_json === null ? null : $this->shipping->getAvailableRates($checkout->store, $checkout->shipping_address_json)->firstWhere('id', $rateId);

        if ($rate === null) {
            throw new \InvalidArgumentException('The selected shipping method is unavailable.');
        }

        $checkout->update(['shipping_rate_id' => $rate->getKey(), 'shipping_method_id' => $rate->getKey(), 'status' => CheckoutStatus::ShippingSelected]);
        $this->pricing->calculate($checkout->refresh());

        return $checkout->refresh();
    }

    public function selectPaymentMethod(Checkout $checkout, string $method): Checkout
    {
        Validator::make(['method' => $method], ['method' => ['required', 'in:credit_card,paypal,bank_transfer']])->validate();

        $checkout->loadMissing('cart.lines.variant');

        if ($checkout->shipping_address_json === null) {
            throw new \LogicException('An address is required before selecting payment.');
        }

        if ($this->requiresShipping($checkout) && $checkout->shipping_rate_id === null) {
            throw new \LogicException('A shipping method is required before selecting payment.');
        }

        if ($checkout->status === CheckoutStatus::PaymentSelected) {
            return $checkout->refresh();
        }

        $checkout->load('cart.lines.variant.inventory');

        DB::transaction(function () use ($checkout, $method): void {
            foreach ($checkout->cart->lines as $line) {
                if ($line->variant->inventory !== null) {
                    $this->inventory->reserve($line->variant->inventory, $line->quantity);
                }
            }

            $checkout->update(['payment_method' => $method, 'status' => CheckoutStatus::PaymentSelected, 'expires_at' => now()->addHours(24)]);
        });

        return $checkout->refresh();
    }

    public function expireCheckout(Checkout $checkout): void
    {
        if ($checkout->status === CheckoutStatus::Expired || $checkout->status === CheckoutStatus::Completed) {
            return;
        }

        DB::transaction(function () use ($checkout): void {
            if ($checkout->status === CheckoutStatus::PaymentSelected) {
                $checkout->load('cart.lines.variant.inventory');

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

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

    private function requiresShipping(Checkout $checkout): bool
    {
        return $checkout->cart->lines->contains(fn ($line): bool => (bool) $line->variant->requires_shipping);
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
app/Http/Requests/StoreShippingRateRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreShippingRateRequest.php:8:class StoreShippingRateRequest extends FormRequest
app/Http/Controllers/Api/StorefrontCheckoutController.php:51:    public function address(Request $request, int $checkoutId): JsonResponse
app/Http/Controllers/Api/StorefrontCheckoutController.php:87:    public function applyDiscount(Request $request, int $checkoutId): JsonResponse
app/Http/Requests/StoreProductRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreProductRequest.php:9:class StoreProductRequest extends FormRequest
app/Http/Requests/UpdateShippingZoneRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateShippingZoneRequest.php:8:class UpdateShippingZoneRequest extends FormRequest
app/Http/Requests/UpdateDiscountRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateDiscountRequest.php:10:class UpdateDiscountRequest extends FormRequest
app/Http/Requests/StoreShippingZoneRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreShippingZoneRequest.php:8:class StoreShippingZoneRequest extends FormRequest
app/Http/Requests/CreateFulfillmentRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/CreateFulfillmentRequest.php:9:class CreateFulfillmentRequest extends FormRequest
app/Http/Requests/StoreDiscountRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreDiscountRequest.php:10:class StoreDiscountRequest extends FormRequest
app/Http/Requests/UpdateStoreSettingsRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateStoreSettingsRequest.php:8:class UpdateStoreSettingsRequest extends FormRequest
app/Http/Requests/UpdateTaxSettingsRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateTaxSettingsRequest.php:8:class UpdateTaxSettingsRequest extends FormRequest
app/Http/Requests/RegisterCustomerRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/RegisterCustomerRequest.php:7:class RegisterCustomerRequest extends FormRequest
app/Http/Requests/ApplyDiscountRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/ApplyDiscountRequest.php:7:class ApplyDiscountRequest extends FormRequest
app/Http/Requests/UpdateCollectionRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateCollectionRequest.php:9:class UpdateCollectionRequest extends FormRequest
app/Http/Requests/LoginRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/LoginRequest.php:7:class LoginRequest extends FormRequest
app/Http/Requests/StorePageRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StorePageRequest.php:8:class StorePageRequest extends FormRequest
app/Http/Requests/UpdateThemeSettingsRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateThemeSettingsRequest.php:8:class UpdateThemeSettingsRequest extends FormRequest
app/Http/Requests/StoreCollectionRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreCollectionRequest.php:9:class StoreCollectionRequest extends FormRequest
app/Http/Requests/InviteStaffRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/InviteStaffRequest.php:8:class InviteStaffRequest extends FormRequest
app/Http/Requests/SetCheckoutAddressRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/SetCheckoutAddressRequest.php:7:class SetCheckoutAddressRequest extends FormRequest
app/Http/Requests/CreateRefundRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/CreateRefundRequest.php:9:class CreateRefundRequest extends FormRequest
app/Http/Requests/StoreThemeRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/StoreThemeRequest.php:8:class StoreThemeRequest extends FormRequest
app/Http/Requests/UpdatePageRequest.php:5:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdatePageRequest.php:8:class UpdatePageRequest extends FormRequest
app/Http/Requests/UpdateProductRequest.php:6:use Illuminate\Foundation\Http\FormRequest;
app/Http/Requests/UpdateProductRequest.php:9:class UpdateProductRequest extends FormRequest
<?php

namespace App\Models;

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

class Checkout extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'cart_id', 'customer_id', 'status', 'email', 'payment_method', 'shipping_address_json', 'billing_address_json', 'shipping_rate_id', 'shipping_method_id', 'discount_code', 'totals_json', 'tax_provider_snapshot_json', 'expires_at'];

    protected function casts(): array
    {
        return ['status' => CheckoutStatus::class, 'shipping_address_json' => 'array', 'billing_address_json' => 'array', 'totals_json' => 'array', 'tax_provider_snapshot_json' => 'array', 'expires_at' => 'datetime'];
    }

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

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

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

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

    public function isExpired(): bool
    {
        return $this->expires_at !== null && $this->expires_at->isPast();
    }
}
<?php

namespace App\Services;

use App\Enums\CheckoutStatus;
use App\Models\Cart;
use App\Models\Checkout;
use App\Models\Customer;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;

class CheckoutService
{
    public function __construct(private readonly PricingEngine $pricing, private readonly ShippingCalculator $shipping, private readonly InventoryService $inventory) {}

    public function create(Cart $cart, string $email, ?Customer $customer = null): Checkout
    {
        $cart->load('lines');

        if ($cart->lines->isEmpty()) {
            throw new \InvalidArgumentException('Cannot checkout an empty cart.');
        }

        Validator::make(['email' => $email], ['email' => ['required', 'email']])->validate();

        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)]);
    }

    public function setAddress(Checkout $checkout, array $address, ?array $billing = null, bool $useShippingAsBilling = true): Checkout
    {
        if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) {
            throw new \LogicException('This checkout can no longer be changed.');
        }

        Validator::make($address, ['first_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'], 'address1' => ['required', 'string', 'max:500'], 'city' => ['required', 'string', 'max:255'], 'country_code' => ['required', 'string', 'size:2'], 'postal_code' => ['required', 'string', 'max:20']])->validate();
        $checkout->update(['shipping_address_json' => $address, 'billing_address_json' => $useShippingAsBilling ? $address : $billing, 'status' => CheckoutStatus::Addressed]);
        $this->pricing->calculate($checkout->refresh());

        return $checkout->refresh();
    }

    public function setShippingMethod(Checkout $checkout, int $rateId): Checkout
    {
        $checkout->loadMissing('cart.lines.variant');

        if (! $this->requiresShipping($checkout)) {
            $checkout->update(['shipping_rate_id' => null, 'shipping_method_id' => null, 'status' => CheckoutStatus::ShippingSelected]);
            $this->pricing->calculate($checkout->refresh());

            return $checkout->refresh();
        }

        if ($checkout->shipping_address_json === null) {
            throw new \LogicException('An address is required before selecting shipping.');
        }

        $rate = $checkout->shipping_address_json === null ? null : $this->shipping->getAvailableRates($checkout->store, $checkout->shipping_address_json)->firstWhere('id', $rateId);

        if ($rate === null) {
            throw new \InvalidArgumentException('The selected shipping method is unavailable.');
        }

        $checkout->update(['shipping_rate_id' => $rate->getKey(), 'shipping_method_id' => $rate->getKey(), 'status' => CheckoutStatus::ShippingSelected]);
        $this->pricing->calculate($checkout->refresh());

        return $checkout->refresh();
    }

    public function selectPaymentMethod(Checkout $checkout, string $method): Checkout
    {
        Validator::make(['method' => $method], ['method' => ['required', 'in:credit_card,paypal,bank_transfer']])->validate();

        $checkout->loadMissing('cart.lines.variant');

        if ($checkout->shipping_address_json === null) {
            throw new \LogicException('An address is required before selecting payment.');
        }

        if ($this->requiresShipping($checkout) && $checkout->shipping_rate_id === null) {
            throw new \LogicException('A shipping method is required before selecting payment.');
        }

        if ($checkout->status === CheckoutStatus::PaymentSelected) {
            return $checkout->refresh();
        }

        $checkout->load('cart.lines.variant.inventory');

        DB::transaction(function () use ($checkout, $method): void {
            foreach ($checkout->cart->lines as $line) {
                if ($line->variant->inventory !== null) {
                    $this->inventory->reserve($line->variant->inventory, $line->quantity);
                }
            }

            $checkout->update(['payment_method' => $method, 'status' => CheckoutStatus::PaymentSelected, 'expires_at' => now()->addHours(24)]);
        });

        return $checkout->refresh();
    }

    public function expireCheckout(Checkout $checkout): void
    {
        if ($checkout->status === CheckoutStatus::Expired || $checkout->status === CheckoutStatus::Completed) {
            return;
        }

        DB::transaction(function () use ($checkout): void {
            if ($checkout->status === CheckoutStatus::PaymentSelected) {
                $checkout->load('cart.lines.variant.inventory');

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

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

    private function requiresShipping(Checkout $checkout): bool
    {
        return $checkout->cart->lines->contains(fn ($line): bool => (bool) $line->variant->requires_shipping);
    }
}


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "rg -n \"Form Request|validation|checkout|address|payment\" specs/02-API-ROUTES.md specs/05-BUSINESS-LOGIC.md | head -180 && sed -n '1,240p' tests/Feature/CommerceValidationAndCleanupTest.php && sed -n '1,220p' tests/Feature/ContractBehaviorTest.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 10000,
  max_output_tokens: 30000
});
text(r.output);


command: rg -n "Form Request|validation|checkout|address|payment" specs/02-API-ROUTES.md specs/05-BUSINESS-LOGIC.md | head -180 && sed -n '1,240p' tests/Feature/CommerceValidationAndCleanupTest.php && sed -n '1,220p' tests/Feature/ContractBehaviorTest.php

type: search

cmd: rg -n 'Form Request|validation|checkout|address|payment' specs/02-API-ROUTES.md specs/05-BUSINESS-LOGIC.md

query: Form Request|validation|checkout|address|payment

ContractBehaviorTest.php

stdout:
specs/02-API-ROUTES.md:3:This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.
specs/02-API-ROUTES.md:71:| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |
specs/02-API-ROUTES.md:78:| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |
specs/02-API-ROUTES.md:171:| GET | `/account/addresses` | Address book management | `Storefront\Account\Addresses\Index` |
specs/02-API-ROUTES.md:192:| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\Checkout\Show` |
specs/02-API-ROUTES.md:193:| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\Checkout\Confirmation` |
specs/02-API-ROUTES.md:203:These endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).
specs/02-API-ROUTES.md:425:    C->>API: POST /checkouts (cart_id, email)
specs/02-API-ROUTES.md:428:    C->>API: PUT /checkouts/{id}/address
specs/02-API-ROUTES.md:429:    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)
specs/02-API-ROUTES.md:431:    C->>API: PUT /checkouts/{id}/shipping-method
specs/02-API-ROUTES.md:435:        C->>API: POST /checkouts/{id}/apply-discount
specs/02-API-ROUTES.md:439:    C->>API: POST /checkouts/{id}/pay (payment_method + details)
specs/02-API-ROUTES.md:441:    API->>MockPSP: charge(checkout, paymentMethodData)
specs/02-API-ROUTES.md:458:#### POST /api/storefront/v1/checkouts
specs/02-API-ROUTES.md:460:Create a checkout from a cart.
specs/02-API-ROUTES.md:463:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:477:| `email` | string | Yes | Valid email address. |
specs/02-API-ROUTES.md:489:  "shipping_address_json": null,
specs/02-API-ROUTES.md:490:  "billing_address_json": null,
specs/02-API-ROUTES.md:524:#### GET /api/storefront/v1/checkouts/{checkoutId}
specs/02-API-ROUTES.md:526:Retrieve the current checkout state and computed totals.
specs/02-API-ROUTES.md:528:- **Authentication:** None (checkout ID acts as token)
specs/02-API-ROUTES.md:529:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:533:Returns the full checkout object (same schema as POST response, with updated fields reflecting the current state).
specs/02-API-ROUTES.md:541:#### PUT /api/storefront/v1/checkouts/{checkoutId}/address
specs/02-API-ROUTES.md:543:Set the shipping and/or billing address on a checkout.
specs/02-API-ROUTES.md:546:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:552:  "shipping_address": {
specs/02-API-ROUTES.md:555:    "address1": "123 Main St",
specs/02-API-ROUTES.md:556:    "address2": "Apt 4B",
specs/02-API-ROUTES.md:565:  "billing_address": null,
specs/02-API-ROUTES.md:570:**Shipping address fields:**
specs/02-API-ROUTES.md:574:| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |
specs/02-API-ROUTES.md:575:| `shipping_address.first_name` | string | Yes | Max: 255 |
specs/02-API-ROUTES.md:576:| `shipping_address.last_name` | string | Yes | Max: 255 |
specs/02-API-ROUTES.md:577:| `shipping_address.address1` | string | Yes | Max: 500 |
specs/02-API-ROUTES.md:578:| `shipping_address.address2` | string | No | Max: 500 |
specs/02-API-ROUTES.md:579:| `shipping_address.city` | string | Yes | Max: 255 |
specs/02-API-ROUTES.md:580:| `shipping_address.province` | string | No | Max: 255 |
specs/02-API-ROUTES.md:581:| `shipping_address.province_code` | string | No | Max: 10 |
specs/02-API-ROUTES.md:582:| `shipping_address.country` | string | Yes | Max: 255 |
specs/02-API-ROUTES.md:583:| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |
specs/02-API-ROUTES.md:584:| `shipping_address.postal_code` | string | Yes | Max: 20 |
specs/02-API-ROUTES.md:585:| `shipping_address.phone` | string | No | Max: 50 |
specs/02-API-ROUTES.md:586:| `billing_address` | object | No | Same fields as shipping_address |
specs/02-API-ROUTES.md:587:| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |
specs/02-API-ROUTES.md:591:Returns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.
specs/02-API-ROUTES.md:596:  "status": "addressed",
specs/02-API-ROUTES.md:597:  "shipping_address_json": {
specs/02-API-ROUTES.md:600:    "address1": "123 Main St",
specs/02-API-ROUTES.md:601:    "address2": "Apt 4B",
specs/02-API-ROUTES.md:610:  "billing_address_json": {
specs/02-API-ROUTES.md:613:    "address1": "123 Main St",
specs/02-API-ROUTES.md:614:    "address2": "Apt 4B",
specs/02-API-ROUTES.md:657:- `422` - Validation errors (missing required fields, invalid country code, address not in any shipping zone)
specs/02-API-ROUTES.md:661:#### PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method
specs/02-API-ROUTES.md:663:Select a shipping method for the checkout.
specs/02-API-ROUTES.md:666:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:678:| `shipping_method_id` | integer | Yes | Must be one of the available shipping methods for this checkout's address. |
specs/02-API-ROUTES.md:682:Returns the full checkout object. Status transitions to `shipping_selected`. Totals are recalculated including the shipping cost and taxes (if applicable).
specs/02-API-ROUTES.md:717:- `422` - Invalid shipping method (not available for this address/checkout)
specs/02-API-ROUTES.md:721:#### PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method
specs/02-API-ROUTES.md:723:Select a payment method for the checkout. This step records the chosen method without processing payment.
specs/02-API-ROUTES.md:726:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:732:  "payment_method": "credit_card"
specs/02-API-ROUTES.md:738:| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |
specs/02-API-ROUTES.md:742:Returns the full checkout object. Status transitions to `payment_selected`.
specs/02-API-ROUTES.md:747:  "status": "payment_selected",
specs/02-API-ROUTES.md:748:  "payment_method": "credit_card",
specs/02-API-ROUTES.md:763:- `422` - Invalid payment method or checkout not in `shipping_selected` state
specs/02-API-ROUTES.md:767:#### POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount
specs/02-API-ROUTES.md:769:Apply a discount code to the checkout.
specs/02-API-ROUTES.md:772:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:829:#### DELETE /api/storefront/v1/checkouts/{checkoutId}/discount
specs/02-API-ROUTES.md:831:Remove the applied discount code from the checkout.
specs/02-API-ROUTES.md:834:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:838:Returns the full checkout object with discount removed and totals recalculated.
specs/02-API-ROUTES.md:848:#### POST /api/storefront/v1/checkouts/{checkoutId}/pay
specs/02-API-ROUTES.md:850:Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.
specs/02-API-ROUTES.md:853:- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md:859:  "payment_method": "credit_card",
specs/02-API-ROUTES.md:871:  "payment_method": "paypal"
specs/02-API-ROUTES.md:879:  "payment_method": "bank_transfer"
specs/02-API-ROUTES.md:885:| `payment_method` | string | Yes | Must be one of: `credit_card`, `paypal`, `bank_transfer`. |
specs/02-API-ROUTES.md:886:| `card_number` | string | Conditional | Required when `payment_method = credit_card`. Digits only (spaces stripped). |
specs/02-API-ROUTES.md:887:| `card_expiry` | string | Conditional | Required when `payment_method = credit_card`. Format: MM/YY. |
specs/02-API-ROUTES.md:888:| `card_cvc` | string | Conditional | Required when `payment_method = credit_card`. 3-4 digits. |
specs/02-API-ROUTES.md:889:| `card_holder` | string | Conditional | Required when `payment_method = credit_card`. Max 255 chars. |
specs/02-API-ROUTES.md:895:  "checkout_id": 7,
specs/02-API-ROUTES.md:902:    "payment_method": "credit_card",
specs/02-API-ROUTES.md:909:**Success response (Bank Transfer - deferred payment):** `200 OK`
specs/02-API-ROUTES.md:913:  "checkout_id": 7,
specs/02-API-ROUTES.md:920:    "payment_method": "bank_transfer",
specs/02-API-ROUTES.md:936:- `409` - Checkout not in a valid state for payment (must be `payment_selected`)
specs/02-API-ROUTES.md:937:- `422` - Validation errors (missing card fields, invalid payment method)
specs/02-API-ROUTES.md:999:  "shipping_address": {
specs/02-API-ROUTES.md:1002:    "address1": "123 Main St",
specs/02-API-ROUTES.md:1186:| `events[].type` | string | Yes | One of: `page_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. |
specs/02-API-ROUTES.md:1955:    "billing_address_json": {
specs/02-API-ROUTES.md:1958:      "address1": "123 Main St",
specs/02-API-ROUTES.md:1963:    "shipping_address_json": {
specs/02-API-ROUTES.md:1966:      "address1": "123 Main St",
specs/02-API-ROUTES.md:1989:    "payments": [
specs/02-API-ROUTES.md:1994:        "provider_payment_id": "mock_abc123",
specs/02-API-ROUTES.md:2113:    "payment_id": 401,
specs/02-API-ROUTES.md:2554:- `422` - Theme has validation errors preventing publish
specs/02-API-ROUTES.md:2801:      "checkout_started_count": 420,
specs/02-API-ROUTES.md:2813:        "checkout_started_count": 35
specs/02-API-ROUTES.md:2899:> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).
specs/02-API-ROUTES.md:3004:| `ShareErrorsFromSession` | Flash validation errors |
specs/02-API-ROUTES.md:3015:| `verified` | Require verified email address |
specs/02-API-ROUTES.md:3080:| `login` | 5 requests | 1 minute | IP address | Admin and customer login attempts |
specs/02-API-ROUTES.md:3082:| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |
specs/02-API-ROUTES.md:3083:| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |
specs/02-API-ROUTES.md:3084:| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |
specs/02-API-ROUTES.md:3085:| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |
specs/02-API-ROUTES.md:3086:| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |
specs/02-API-ROUTES.md:3117:| `POST /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3118:| `GET /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3119:| `PUT /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3120:| `DELETE /api/storefront/v1/checkouts*` | `checkout` |
specs/02-API-ROUTES.md:3121:| `POST /api/storefront/v1/checkouts/*/pay` | `checkout` |
specs/02-API-ROUTES.md:3136:The checkout follows a strict state machine with explicit transitions:
specs/02-API-ROUTES.md:3140:    [*] --> started : POST /checkouts (create from cart)
specs/02-API-ROUTES.md:3141:    started --> addressed : PUT /checkouts/{id}/address
specs/02-API-ROUTES.md:3142:    addressed --> shipping_selected : PUT /checkouts/{id}/shipping-method
specs/02-API-ROUTES.md:3143:    shipping_selected --> payment_selected : PUT /checkouts/{id}/payment-method
specs/02-API-ROUTES.md:3144:    payment_selected --> completed : POST /checkouts/{id}/pay (instant capture: credit_card/paypal)
specs/02-API-ROUTES.md:3145:    payment_selected --> completed : POST /checkouts/{id}/pay (bank_transfer: deferred, order created as pending)
specs/02-API-ROUTES.md:3146:    payment_selected --> shipping_selected : Payment declined, customer retries
specs/02-API-ROUTES.md:3148:    addressed --> expired : 24-hour TTL reached
specs/02-API-ROUTES.md:3150:    payment_selected --> expired : 24-hour TTL reached
specs/02-API-ROUTES.md:3158:| `started` | `addressed` | PUT /checkouts/{id}/address with valid address |
specs/02-API-ROUTES.md:3159:| `addressed` | `shipping_selected` | PUT /checkouts/{id}/shipping-method with valid method |
specs/02-API-ROUTES.md:3160:| `shipping_selected` | `payment_selected` | PUT /checkouts/{id}/payment-method with valid method |
specs/02-API-ROUTES.md:3161:| `payment_selected` | `completed` | POST /checkouts/{id}/pay - Mock PSP processes payment in-process (instant for card/paypal, deferred for bank_transfer) |
specs/02-API-ROUTES.md:3162:| `payment_selected` | `shipping_selected` | Payment declined by Mock PSP (magic card number), customer retries |
specs/02-API-ROUTES.md:3165:Applying or removing a discount does not change the checkout status but triggers totals recalculation at the current state.
specs/05-BUSINESS-LOGIC.md:80:products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
specs/05-BUSINESS-LOGIC.md:208:| `quantity_reserved` | Units held for active checkouts or pending orders |
specs/05-BUSINESS-LOGIC.md:230:        B -->|Commit on payment| C[Deducted]
specs/05-BUSINESS-LOGIC.md:240:| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
specs/05-BUSINESS-LOGIC.md:291:**Add line - validations (in order):**
specs/05-BUSINESS-LOGIC.md:300:8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).
specs/05-BUSINESS-LOGIC.md:330:line_discount_amount = 0  (updated by discount engine during checkout)
specs/05-BUSINESS-LOGIC.md:343:- Releases any reserved inventory associated with active checkouts for these carts.
specs/05-BUSINESS-LOGIC.md:405:- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.
specs/05-BUSINESS-LOGIC.md:406:- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.
specs/05-BUSINESS-LOGIC.md:415:**Enum:** `App\Enums\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`
specs/05-BUSINESS-LOGIC.md:420:    started --> addressed : Address provided
specs/05-BUSINESS-LOGIC.md:421:    addressed --> shipping_selected : Shipping method chosen
specs/05-BUSINESS-LOGIC.md:422:    shipping_selected --> payment_selected : Payment method selected
specs/05-BUSINESS-LOGIC.md:423:    payment_selected --> completed : Payment confirmed
specs/05-BUSINESS-LOGIC.md:424:    payment_selected --> expired : Timeout (24h)
specs/05-BUSINESS-LOGIC.md:427:    addressed --> expired : Timeout (24h)
specs/05-BUSINESS-LOGIC.md:430:    note right of addressed : Triggers pricing recalculation
specs/05-BUSINESS-LOGIC.md:432:    note right of payment_selected : Inventory reserved
specs/05-BUSINESS-LOGIC.md:441:#### Transition: started -> addressed
specs/05-BUSINESS-LOGIC.md:443:**Method:** `setAddress(checkout, addressData)`
specs/05-BUSINESS-LOGIC.md:446:- `email` - valid email address
specs/05-BUSINESS-LOGIC.md:447:- `shipping_address` with required fields:
specs/05-BUSINESS-LOGIC.md:453:| `address1` | Yes | string | |
specs/05-BUSINESS-LOGIC.md:454:| `address2` | No | string | |
specs/05-BUSINESS-LOGIC.md:464:1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).
specs/05-BUSINESS-LOGIC.md:465:2. Save email and shipping_address_json on the checkout.
specs/05-BUSINESS-LOGIC.md:466:3. Copy shipping address to billing_address_json by default (can be overridden later).
specs/05-BUSINESS-LOGIC.md:467:4. Recalculate pricing (shipping zones and tax regions may change based on address).
specs/05-BUSINESS-LOGIC.md:468:5. Transition status to `addressed`.
specs/05-BUSINESS-LOGIC.md:471:#### Transition: addressed -> shipping_selected
specs/05-BUSINESS-LOGIC.md:473:**Method:** `setShippingMethod(checkout, shippingRateId)`
specs/05-BUSINESS-LOGIC.md:476:- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.
specs/05-BUSINESS-LOGIC.md:480:1. Resolve available shipping zones for the address (see Section 9).
specs/05-BUSINESS-LOGIC.md:482:3. Save shipping_method_id on the checkout.
specs/05-BUSINESS-LOGIC.md:487:#### Transition: shipping_selected -> payment_selected
specs/05-BUSINESS-LOGIC.md:489:**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method
specs/05-BUSINESS-LOGIC.md:492:- All previous data is valid (address, shipping method).
specs/05-BUSINESS-LOGIC.md:494:- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.
specs/05-BUSINESS-LOGIC.md:497:1. Store `payment_method` on the checkout record.
specs/05-BUSINESS-LOGIC.md:499:3. Set `expires_at` on the checkout to current time + 24 hours.
<?php

use App\Enums\CheckoutStatus;
use App\Jobs\CleanupAbandonedCarts;
use App\Jobs\ExpireAbandonedCheckouts;
use App\Models\Checkout;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\Store;
use App\Services\CartService;
use App\Services\InventoryService;
use Database\Seeders\ShopSeeder;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;

uses(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);
    $this->variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
});

test('checkout addresses validate every required nested field', function (): void {
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
        'variant_id' => $this->variant->getKey(),
        'quantity' => 1,
        'cart_version' => 1,
    ])->assertCreated();
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [
        'cart_id' => $cart['id'],
        'email' => 'address-validation@example.test',
    ])->assertCreated()->json();

    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [
        'shipping_address' => ['first_name' => 'Incomplete'],
    ])->assertUnprocessable()->assertJsonValidationErrors([
        'shipping_address.last_name',
        'shipping_address.address1',
        'shipping_address.city',
        'shipping_address.country',
        'shipping_address.country_code',
        'shipping_address.postal_code',
    ]);

    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [
        'shipping_address' => [
            'first_name' => 'Jane',
            'last_name' => 'Doe',
            'address1' => '123 Main St',
            'city' => 'Berlin',
            'country' => 'Germany',
            'country_code' => 'DE',
            'postal_code' => '10115',
        ],
        'use_shipping_as_billing' => false,
    ])->assertUnprocessable()->assertJsonValidationErrors([
        'billing_address',
        'billing_address.first_name',
    ]);
});

test('credit card payments require all card fields while other payment methods do not', function (): void {
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
        'variant_id' => $this->variant->getKey(),
        'quantity' => 1,
        'cart_version' => 1,
    ])->assertCreated();
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [
        'cart_id' => $cart['id'],
        'email' => 'card-validation@example.test',
    ])->assertCreated()->json();
    $address = [
        'first_name' => 'Jane',
        'last_name' => 'Doe',
        'address1' => '123 Main St',
        'city' => 'Berlin',
        'country' => 'Germany',
        'country_code' => 'DE',
        'postal_code' => '10115',
    ];
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => $address])->assertOk();
    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()])->assertOk();

    $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", [
        'payment_method' => 'credit_card',
        'card_number' => '4242 4242 4242 4242',
    ])->assertUnprocessable()->assertJsonValidationErrors([
        'card_expiry',
        'card_cvc',
        'card_holder',
    ]);
});

test('abandoned cart cleanup does not release inventory owned by another checkout', function (): void {
    $carts = app(CartService::class);
    $inventory = InventoryItem::query()->where('variant_id', $this->variant->getKey())->firstOrFail();

    $expiredCart = $carts->create($this->store);
    $carts->addLine($expiredCart, $this->variant->getKey(), 1);
    $expiredCheckout = Checkout::withoutGlobalScopes()->create([
        'store_id' => $this->store->getKey(),
        'cart_id' => $expiredCart->getKey(),
        'email' => 'expired@example.test',
        'status' => CheckoutStatus::PaymentSelected,
        'expires_at' => now()->subMinute(),
    ]);
    app(InventoryService::class)->reserve($inventory, 1);

    app(ExpireAbandonedCheckouts::class)->handle(app(InventoryService::class));

    $activeCart = $carts->create($this->store);
    $carts->addLine($activeCart, $this->variant->getKey(), 1);
    $activeCheckout = Checkout::withoutGlobalScopes()->create([
        'store_id' => $this->store->getKey(),
        'cart_id' => $activeCart->getKey(),
        'email' => 'active@example.test',
        'status' => CheckoutStatus::PaymentSelected,
        'expires_at' => now()->addHour(),
    ]);
    app(InventoryService::class)->reserve($inventory->refresh(), 1);
    DB::table('carts')->where('id', $expiredCart->getKey())->update(['updated_at' => now()->subDays(15)]);

    app(CleanupAbandonedCarts::class)->handle(app(InventoryService::class));

    expect($expiredCheckout->refresh()->status)->toBe(CheckoutStatus::Expired)
        ->and($activeCheckout->refresh()->status)->toBe(CheckoutStatus::PaymentSelected)
        ->and($expiredCart->refresh()->status->value)->toBe('abandoned')
        ->and($inventory->refresh()->quantity_reserved)->toBe(1);
});

test('commerce rate limiters match the specification', function (): void {
    $request = Request::create('/api/storefront/v1/search', 'GET', [], [], [], ['REMOTE_ADDR' => '192.0.2.1']);
    $search = RateLimiter::limiter('search');
    $webhooks = RateLimiter::limiter('webhooks');

    expect($search)->not->toBeNull()
        ->and($webhooks)->not->toBeNull()
        ->and($search($request)->maxAttempts)->toBe(30)
        ->and($webhooks($request)->maxAttempts)->toBe(100)
        ->and($search($request)->decaySeconds)->toBe(60)
        ->and($webhooks($request)->decaySeconds)->toBe(60);
});
<?php

use App\Models\Customer;
use App\Models\Store;
use App\Models\Theme;
use App\Models\ThemeSetting;
use App\Models\WebhookDelivery;
use App\Models\WebhookSubscription;
use App\Services\WebhookService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Schema;

uses(LazilyRefreshDatabase::class);

test('theme settings use a one-to-one JSON contract and preserve model relations', function (): void {
    $columns = collect(Schema::getColumns('theme_settings'))->keyBy('name');
    $foreignKeys = collect(DB::select("PRAGMA foreign_key_list('theme_settings')"));

    expect($columns->keys()->all())->toBe(['theme_id', 'settings_json', 'updated_at'])
        ->and($columns['theme_id']['nullable'])->toBeFalse()
        ->and($columns['settings_json']['default'])->toBe("'{}'")
        ->and($foreignKeys->pluck('table')->all())->toContain('themes');

    $theme = Theme::factory()->create();
    $setting = $theme->themeSettings()->create([
        'settings_json' => ['primary_color' => '#1a1a2e', 'dark_mode' => true],
    ]);

    expect($setting)->toBeInstanceOf(ThemeSetting::class)
        ->and($setting->getKey())->toBe($theme->getKey())
        ->and($theme->fresh()->settingsRows->settings_json)->toBe([
            'primary_color' => '#1a1a2e',
            'dark_mode' => true,
        ]);
});

test('customer password reset tokens are non-null foreign-keyed and store scoped', function (): void {
    $columns = collect(Schema::getColumns('customer_password_reset_tokens'))->keyBy('name');
    $foreignKeys = collect(DB::select("PRAGMA foreign_key_list('customer_password_reset_tokens')"));

    expect($columns['store_id']['nullable'])->toBeFalse()
        ->and($foreignKeys->pluck('table')->all())->toContain('stores');

    $firstStore = Store::factory()->create();
    $secondStore = Store::factory()->create();
    $firstCustomer = Customer::factory()->create(['store_id' => $firstStore->getKey(), 'email' => 'same@example.test']);
    $secondCustomer = Customer::factory()->create(['store_id' => $secondStore->getKey(), 'email' => 'same@example.test']);

    app()->instance('current_store', $firstStore);
    $firstToken = Password::broker('customers')->createToken($firstCustomer);

    expect(DB::table('customer_password_reset_tokens')->where('store_id', $firstStore->getKey())->where('email', $firstCustomer->email)->exists())->toBeTrue();

    app()->instance('current_store', $secondStore);

    expect(Password::broker('customers')->tokenExists($secondCustomer, $firstToken))->toBeFalse();

    $secondToken = Password::broker('customers')->createToken($secondCustomer);

    expect(Password::broker('customers')->tokenExists($secondCustomer, $secondToken))->toBeTrue();
});

test('security defaults enable encrypted sessions, CORS credentials, and ninety-day audit retention', function (): void {
    $sessionEncryptEnvironment = $_ENV['SESSION_ENCRYPT'] ?? null;
    $sessionEncryptServer = $_SERVER['SESSION_ENCRYPT'] ?? null;
    $hasEnvironmentValue = array_key_exists('SESSION_ENCRYPT', $_ENV);
    $hasServerValue = array_key_exists('SESSION_ENCRYPT', $_SERVER);

    unset($_ENV['SESSION_ENCRYPT'], $_SERVER['SESSION_ENCRYPT']);
    putenv('SESSION_ENCRYPT');
    $sessionConfig = require config_path('session.php');

    if ($hasEnvironmentValue) {
        $_ENV['SESSION_ENCRYPT'] = $sessionEncryptEnvironment;
    }

    if ($hasServerValue) {
        $_SERVER['SESSION_ENCRYPT'] = $sessionEncryptServer;
    }

    expect($sessionConfig['encrypt'])->toBeTrue()
        ->and(config('cors.supports_credentials'))->toBeTrue()
        ->and(config('logging.channels.audit.days'))->toBe(90);
});

test('webhook secrets are encrypted under the contract name and sign the exact JSON request', function (): void {
    Http::fake(['https://hooks.test/*' => Http::response(['ok' => true], 200)]);
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $subscription = WebhookSubscription::create([
        'event' => 'order.created',
        'target_url' => 'https://hooks.test/orders',
        'signing_secret_encrypted' => 'test-secret',
        'status' => 'active',
    ]);

    expect($subscription->signing_secret_encrypted)->toBe('test-secret')
        ->and($subscription->getHidden())->toContain('signing_secret_encrypted')
        ->and(DB::table('webhook_subscriptions')->where('id', $subscription->getKey())->value('signing_secret_encrypted'))->not->toBe('test-secret');

    (new WebhookService)->dispatch($store, 'order.created', ['order_id' => 1001]);

    Http::assertSent(function ($request): bool {
        $timestamp = $request->header('X-Platform-Timestamp')[0];
        $body = $request->body();

        return $request->header('Content-Type')[0] === 'application/json'
            && $request->header('X-Platform-Event')[0] === 'order.created'
            && $request->header('X-Platform-Signature')[0] === hash_hmac('sha256', $timestamp.'.'.$body, 'test-secret');
    });

    expect(WebhookDelivery::query()->where('webhook_subscription_id', $subscription->getKey())->firstOrFail()->status)->toBe('delivered');
});