command: git status --short && sed -n '1,260p' tests/Feature/CommerceFlowTest.php && sed -n '1,300p' tests/Unit/ServicesTest.php 2>/dev/null || true

stdout:
 M app/Http/Middleware/ResolveStore.php
 M app/Providers/AppServiceProvider.php
 M boost.json
 M bootstrap/app.php
 M database/seeders/DatabaseSeeder.php
 M resources/views/layouts/auth.blade.php
 M routes/console.php
 M routes/web.php
 M specs/progress.md
?? .playwright-mcp/
?? app/Auth/
?? app/Contracts/
?? app/Enums/CartStatus.php
?? app/Enums/CheckoutStatus.php
?? app/Enums/CollectionStatus.php
?? app/Enums/DiscountType.php
?? app/Enums/DiscountValueType.php
?? app/Enums/FinancialStatus.php
?? app/Enums/FulfillmentShipmentStatus.php
?? app/Enums/FulfillmentStatus.php
?? app/Enums/InventoryPolicy.php
?? app/Enums/MediaStatus.php
?? app/Enums/MediaType.php
?? app/Enums/OrderStatus.php
?? app/Enums/PageStatus.php
?? app/Enums/PaymentMethod.php
?? app/Enums/PaymentStatus.php
?? app/Enums/ProductStatus.php
?? app/Enums/RefundStatus.php
?? app/Enums/ThemeStatus.php
?? app/Enums/VariantStatus.php
?? app/Events/
?? app/Exceptions/
?? app/Http/Controllers/Api/
?? app/Http/Middleware/EnsureStoreRole.php
?? app/Jobs/
?? app/Livewire/Admin/
?? app/Livewire/Storefront/
?? app/Models/AnalyticsDaily.php
?? app/Models/AnalyticsEvent.php
?? app/Models/App.php
?? app/Models/AppInstallation.php
?? app/Models/Cart.php
?? app/Models/CartLine.php
?? app/Models/Checkout.php
?? app/Models/Collection.php
?? app/Models/Customer.php
?? app/Models/CustomerAddress.php
?? app/Models/Discount.php
?? app/Models/Fulfillment.php
?? app/Models/FulfillmentLine.php
?? app/Models/InventoryItem.php
?? app/Models/NavigationItem.php
?? app/Models/NavigationMenu.php
?? app/Models/Order.php
?? app/Models/OrderLine.php
?? app/Models/Page.php
?? app/Models/Payment.php
?? app/Models/Product.php
?? app/Models/ProductMedia.php
?? app/Models/ProductOption.php
?? app/Models/ProductOptionValue.php
?? app/Models/ProductVariant.php
?? app/Models/Refund.php
?? app/Models/SearchQuery.php
?? app/Models/SearchSetting.php
?? app/Models/ShippingRate.php
?? app/Models/ShippingZone.php
?? app/Models/TaxSettings.php
?? app/Models/Theme.php
?? app/Models/ThemeFile.php
?? app/Models/ThemeSetting.php
?? app/Models/WebhookDelivery.php
?? app/Models/WebhookSubscription.php
?? app/Observers/
?? app/Services/
?? app/Support/
?? app/ValueObjects/
?? config/shop.php
?? database/factories/CartFactory.php
?? database/factories/CollectionFactory.php
?? database/factories/CustomerFactory.php
?? database/factories/DiscountFactory.php
?? database/factories/InventoryItemFactory.php
?? database/factories/OrderFactory.php
?? database/factories/ProductFactory.php
?? database/factories/ProductVariantFactory.php
?? database/migrations/2026_08_20_000000_create_shop_schema.php
?? database/migrations/2026_08_20_220000_add_search_and_auth_support.php
?? database/migrations/2026_08_20_220001_add_webhook_delivery_status.php
?? database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php
?? database/migrations/2026_08_20_220003_add_discount_code_to_carts.php
?? database/seeders/OrganizationSeeder.php
?? database/seeders/ShopSeeder.php
?? database/seeders/StoreDomainSeeder.php
?? database/seeders/StoreSeeder.php
?? database/seeders/StoreSettingsSeeder.php
?? resources/views/components/storefront/
?? resources/views/layouts/admin.blade.php
?? resources/views/layouts/empty.blade.php
?? resources/views/layouts/storefront.blade.php
?? resources/views/livewire/admin/
?? resources/views/livewire/storefront/
?? routes/api.php
?? tests/Feature/CommerceFlowTest.php
?? tests/Feature/SearchAnalyticsWebhookTest.php
?? tests/Feature/Storefront/
?? tests/Unit/PricingEngineTest.php
<?php

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

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

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

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

    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts', ['currency' => 'EUR'])
        ->assertCreated()
        ->json();

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

    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [
        'cart_id' => $cart['id'],
        'email' => 'flow@example.test',
    ])->assertCreated()->json();

    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [
        'shipping_address' => [
            'first_name' => 'Flow',
            'last_name' => 'Tester',
            'address1' => '1 Test Street',
            'city' => 'Berlin',
            'country_code' => 'DE',
            'postal_code' => '10115',
        ],
    ])->assertOk()->assertJsonPath('status', 'addressed');

    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", [
        'shipping_method_id' => $rate->getKey(),
    ])->assertOk()->assertJsonPath('status', 'shipping_selected');

    $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", [
        'payment_method' => 'credit_card',
        'card_number' => '4242424242424242',
    ])->assertOk()->assertJsonPath('order.financial_status', 'paid');

    $order = Order::query()->latest('id')->firstOrFail();

    expect($order->payment_method)->toBe('credit_card')
        ->and($order->financial_status->value)->toBe('paid')
        ->and($order->payments()->firstOrFail()->status)->toBe(PaymentStatus::Captured)
        ->and($order->checkout->status->value)->toBe('completed');
});

test('declined payments release the reservation and do not create an order', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $inventoryBefore = InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved;

    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'declined@example.test'])->json();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Declined', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country_code' => 'DE', 'postal_code' => '10115']]);
    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()]);

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

    expect(InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved)->toBe($inventoryBefore)
        ->and(Order::query()->where('email', 'declined@example.test')->exists())->toBeFalse();
});

test('stale cart versions return a conflict response', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();

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

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

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

    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertCreated();
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'discount-error@example.test'])->assertCreated()->json();

    $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/apply-discount", ['code' => 'MISSING'])
        ->assertUnprocessable()
        ->assertJsonPath('code', 'not_found');
});

test('bank transfer keeps inventory reserved until admin confirmation', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'bank@example.test'])->json();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Bank', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country_code' => 'DE', 'postal_code' => '10115']]);
    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()]);

    $response = $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", ['payment_method' => 'bank_transfer'])->assertOk()->json();
    $order = Order::query()->whereKey($response['order']['id'])->firstOrFail();
    $inventory = InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail();

    expect($order->financial_status->value)->toBe('pending')->and($inventory->quantity_reserved)->toBe(1);

    app(OrderService::class)->confirmPayment($order);
    $inventory = $inventory->refresh();

    expect($order->refresh()->financial_status->value)->toBe('paid')
        ->and($inventory->quantity_on_hand)->toBe(79)
        ->and($inventory->quantity_reserved)->toBe(0);
});


text:
Script completed
Wall time 0.1 seconds
Output:

text:
- `trackingData` - tracking_company, tracking_number, tracking_url

**Process:**

```
PROCEDURE createFulfillment(order, lines, trackingData):
    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')
       IF NOT: throw FulfillmentGuardException

    2. FOR EACH line in request:
       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)
       unfulfilled = order_line.quantity - fulfilledSoFar
       VALIDATE requested quantity <= unfulfilled

    3. CREATE Fulfillment record with status = 'pending' and tracking data

    4. CREATE fulfillment_lines for each order line and quantity

    5. Determine new fulfillment status:
       FOR EACH order_line in order:
           totalFulfilled = SUM(all fulfillment_lines for this order_line)
           IF totalFulfilled < order_line.quantity:
               allFulfilled = false

       IF allFulfilled:
           SET order.fulfillment_status = 'fulfilled'
           SET order.status = 'fulfilled'
       ELSE:
           SET order.fulfillment_status = 'partial'

    6. DISPATCH FulfillmentCreated event
```

#### Fulfillment Status Transitions (Admin Button Actions)

Fulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.

```mermaid
stateDiagram-v2
    [*] --> pending : Fulfillment created (admin clicks "Create fulfillment")
    pending --> shipped : Admin clicks "Mark as shipped"
    shipped --> delivered : Admin clicks "Mark as delivered"

    note right of pending : Fulfillment record created with tracking data
    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer
    note right of delivered : Sets delivered_at timestamp
```

**Admin buttons on each fulfillment card:**

| Button | Shown When | Action |
|---|---|---|
| "Mark as shipped" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |
| "Mark as delivered" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |

When transitioning to `shipped`:
app/Services/DiscountService.php:12:class DiscountService
app/Services/DiscountService.php:26:        $minimum = (int) ($discount->rules_json['minimum_purchase_amount'] ?? 0);
app/Models/Discount.php:10:class Discount extends Model
app/Models/Discount.php:14:    protected $fillable = ['store_id', 'code', 'type', 'value_type', 'value_amount', 'status', 'usage_limit', 'usage_count', 'starts_at', 'ends_at', 'rules_json'];
app/Models/Discount.php:18:        return ['type' => DiscountType::class, 'value_type' => DiscountValueType::class, 'starts_at' => 'datetime', 'ends_at' => 'datetime', 'rules_json' => 'array'];
app/Models/Discount.php:21:    public function isAvailable(): bool

text:
app/Services/SearchService.php:34:            ->latest('published_at')
app/Services/DiscountService.php:12:class DiscountService
app/Services/PricingEngine.php:12:    public function __construct(private readonly DiscountService $discounts, private readonly ShippingCalculator $shipping, private readonly TaxCalculator $taxes) {}
tests/Unit/PricingEngineTest.php:8:use App\Services\DiscountService;
tests/Unit/PricingEngineTest.php:20:    $service = new DiscountService;
tests/Unit/PricingEngineTest.php:38:    $result = (new DiscountService)->calculate($discount, 2500, [['line_id' => 1, 'amount' => 2500]]);
app/Services/CartService.php:109:    public function mergeOnLogin(Cart $guest, Cart $customer): Cart
app/Services/ProductService.php:31:                'published_at' => ($data['status'] ?? ProductStatus::Draft) === ProductStatus::Active ? now() : null,
app/Services/ProductService.php:44:        $updates = array_intersect_key($data, array_flip(['title', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at']));
app/Services/ProductService.php:71:        $product->update(['status' => $newStatus, 'published_at' => $newStatus === ProductStatus::Active ? ($product->published_at ?? now()) : null]);
app/Services/RefundService.php:13:class RefundService
app/Services/RefundService.php:19:        $refunded = (int) $order->refunds()->where('status', 'processed')->sum('amount');
app/Services/RefundService.php:21:        if ($amount < 1 || $refunded + $amount > $payment->amount) {
app/Services/RefundService.php:26:            $result = $this->provider->refund($payment, $amount);
app/Services/RefundService.php:27:            $refund = $order->refunds()->create(['payment_id' => $payment->getKey(), 'amount' => $amount, 'status' => $result->successful ? 'processed' : 'failed', 'provider_refund_id' => $result->reference, 'reason' => $reason, 'restock' => $restock]);
app/Services/RefundService.php:30:                $totalRefunded = (int) $order->refunds()->where('status', 'processed')->sum('amount');
app/Services/RefundService.php:45:            return $refund;
app/Services/MockPaymentProvider.php:35:    public function refund(Payment $payment, int $amount): RefundResult
app/Services/MockPaymentProvider.php:37:        return new RefundResult(true, 'mock_refund_'.Str::lower(Str::random(16)), 'Refund issued.');
app/Services/CheckoutService.php:29:    public function setAddress(Checkout $checkout, array $address, ?array $billing = null, bool $useShippingAsBilling = true): Checkout
app/Http/Controllers/Api/StorefrontCheckoutController.php:12:use App\Services\DiscountService;
app/Http/Controllers/Api/StorefrontCheckoutController.php:21:    public function __construct(private readonly CheckoutService $checkouts, private readonly PricingEngine $pricing, private readonly ShippingCalculator $shipping, private readonly DiscountService $discounts, private readonly PaymentService $payments) {}
app/Http/Controllers/Api/StorefrontCheckoutController.php:47:        $checkout = $this->checkouts->setAddress($this->checkout($checkoutId), $data['shipping_address'], $data['billing_address'] ?? null, $data['use_shipping_as_billing'] ?? true);
app/Livewire/Admin/Orders/Show.php:8:use App\Services\RefundService;
app/Livewire/Admin/Orders/Show.php:17:    public int $refundAmount = 0;
app/Livewire/Admin/Orders/Show.php:39:    public function refund(RefundService $refunds): void
app/Livewire/Admin/Orders/Show.php:44:            $this->addError('refundAmount', 'No payment found.');
app/Livewire/Admin/Orders/Show.php:49:        $refunds->create($this->order, $payment, $this->refundAmount ?: $payment->amount, 'Admin refund', true);
app/Livewire/Admin/Orders/Show.php:51:        $this->order = $this->order->refresh()->load(['lines', 'payments', 'refunds']);
app/Jobs/AggregateAnalytics.php:28:            $orders = Order::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('placed_at', $date)->whereIn('financial_status', ['paid', 'partially_refunded'])->get();
app/Policies/RefundPolicy.php:20:    public function view(User $user, Refund $refund): bool
app/Policies/RefundPolicy.php:22:        return $this->userHasModelStoreRole($user, $refund, StoreUserRole::cases());
app/Contracts/PaymentProvider.php:15:    public function refund(Payment $payment, int $amount): RefundResult;
app/Enums/PaymentStatus.php:11:    case Refunded = 'refunded';
app/Livewire/Storefront/Checkout/Show.php:38:        $this->checkout = $checkouts->setAddress($this->checkout, $this->shippingAddress);
app/Livewire/Storefront/Home.php:25:        return view('livewire.storefront.home', ['collections' => Collection::query()->where('status', 'active')->withCount('products')->latest()->take(4)->get(), 'products' => Product::query()->published()->with(['variants.inventory', 'media'])->latest('published_at')->take(8)->get()])->layout('layouts.storefront');
app/Enums/OrderStatus.php:13:    case Refunded = 'refunded';
app/Livewire/Storefront/Cart/Show.php:9:use App\Services\DiscountService;
app/Livewire/Storefront/Cart/Show.php:55:    public function applyDiscount(DiscountService $discounts): void
app/Enums/VariantStatus.php:5:enum VariantStatus: string
app/Models/Page.php:13:    protected $fillable = ['store_id', 'title', 'handle', 'content', 'status', 'published_at'];
app/Models/Page.php:17:        return ['status' => PageStatus::class, 'published_at' => 'datetime'];
app/Models/Order.php:44:    public function refunds(): HasMany
app/Models/Product.php:16:    protected $fillable = ['store_id', 'title', 'handle', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at', 'sales_count', 'metadata'];
app/Models/Product.php:20:        return ['status' => ProductStatus::class, 'tags' => 'array', 'metadata' => 'array', 'published_at' => 'datetime'];
app/Models/Product.php:50:        $query->where('status', ProductStatus::Active)->whereNotNull('published_at')->where('published_at', '<=', now());
app/Models/Refund.php:10:    protected $fillable = ['order_id', 'payment_id', 'amount', 'status', 'reason', 'restock', 'provider_refund_id'];
app/Enums/FinancialStatus.php:9:    case PartiallyRefunded = 'partially_refunded';
app/Enums/FinancialStatus.php:10:    case Refunded = 'refunded';

text:
<?php

namespace App\Models;

use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;

class Discount extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'code', 'type', 'value_type', 'value_amount', 'status', 'usage_limit', 'usage_count', 'starts_at', 'ends_at', 'rules_json'];

    protected function casts(): array
    {
        return ['type' => DiscountType::class, 'value_type' => DiscountValueType::class, 'starts_at' => 'datetime', 'ends_at' => 'datetime', 'rules_json' => 'array'];
    }

    public function isAvailable(): bool
    {
        return $this->status === 'active'
            && ($this->starts_at === null || $this->starts_at->isPast())
            && ($this->ends_at === null || $this->ends_at->isFuture())
            && ($this->usage_limit === null || $this->usage_count < $this->usage_limit);
    }
}
<?php

namespace App\Enums;

enum VariantStatus: string
{
    case Active = 'active';
    case Archived = 'archived';
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class OrderLine extends Model
{
    protected $fillable = ['order_id', 'product_id', 'variant_id', 'product_title', 'title_snapshot', 'variant_title', 'sku', 'sku_snapshot', 'quantity', 'unit_price_amount', 'line_subtotal_amount', 'line_discount_amount', 'line_total_amount', 'tax_lines_json', 'discount_allocations_json'];

    protected function casts(): array
    {
        return ['tax_lines_json' => 'array', 'discount_allocations_json' => 'array'];
    }

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

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

    public function variant(): BelongsTo
    {
        return $this->belongsTo(ProductVariant::class, 'variant_id');
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Refund extends Model
{
    protected $fillable = ['order_id', 'payment_id', 'amount', 'status', 'reason', 'restock', 'provider_refund_id'];

    protected function casts(): array
    {
        return ['restock' => 'boolean'];
    }

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

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

text:
 M app/Http/Middleware/ResolveStore.php
 M app/Providers/AppServiceProvider.php
 M boost.json
 M bootstrap/app.php
 M database/seeders/DatabaseSeeder.php
 M resources/views/layouts/auth.blade.php
 M routes/console.php
 M routes/web.php
 M specs/progress.md
?? .playwright-mcp/
?? app/Auth/
?? app/Contracts/
?? app/Enums/CartStatus.php
?? app/Enums/CheckoutStatus.php
?? app/Enums/CollectionStatus.php
?? app/Enums/DiscountType.php
?? app/Enums/DiscountValueType.php
?? app/Enums/FinancialStatus.php
?? app/Enums/FulfillmentShipmentStatus.php
?? app/Enums/FulfillmentStatus.php
?? app/Enums/InventoryPolicy.php
?? app/Enums/MediaStatus.php
?? app/Enums/MediaType.php
?? app/Enums/OrderStatus.php
?? app/Enums/PageStatus.php
?? app/Enums/PaymentMethod.php
?? app/Enums/PaymentStatus.php
?? app/Enums/ProductStatus.php
?? app/Enums/RefundStatus.php
?? app/Enums/ThemeStatus.php
?? app/Enums/VariantStatus.php
?? app/Events/
?? app/Exceptions/
?? app/Http/Controllers/Api/
?? app/Http/Middleware/EnsureStoreRole.php
?? app/Jobs/
?? app/Livewire/Admin/
?? app/Livewire/Storefront/
?? app/Models/AnalyticsDaily.php
?? app/Models/AnalyticsEvent.php
?? app/Models/App.php
?? app/Models/AppInstallation.php
?? app/Models/Cart.php
?? app/Models/CartLine.php
?? app/Models/Checkout.php
?? app/Models/Collection.php
?? app/Models/Customer.php
?? app/Models/CustomerAddress.php
?? app/Models/Discount.php
?? app/Models/Fulfillment.php
?? app/Models/FulfillmentLine.php
?? app/Models/InventoryItem.php
?? app/Models/NavigationItem.php
?? app/Models/NavigationMenu.php
?? app/Models/Order.php
?? app/Models/OrderLine.php
?? app/Models/Page.php
?? app/Models/Payment.php
?? app/Models/Product.php
?? app/Models/ProductMedia.php
?? app/Models/ProductOption.php
?? app/Models/ProductOptionValue.php
?? app/Models/ProductVariant.php
?? app/Models/Refund.php
?? app/Models/SearchQuery.php
?? app/Models/SearchSetting.php
?? app/Models/ShippingRate.php
?? app/Models/ShippingZone.php
?? app/Models/TaxSettings.php
?? app/Models/Theme.php
?? app/Models/ThemeFile.php
?? app/Models/ThemeSetting.php
?? app/Models/WebhookDelivery.php
?? app/Models/WebhookSubscription.php
?? app/Observers/
?? app/Services/
?? app/Support/
?? app/ValueObjects/
?? config/shop.php
?? database/factories/CartFactory.php
?? database/factories/CollectionFactory.php
?? database/factories/CustomerFactory.php
?? database/factories/DiscountFactory.php
?? database/factories/InventoryItemFactory.php
?? database/factories/OrderFactory.php
?? database/factories/ProductFactory.php
?? database/factories/ProductVariantFactory.php
?? database/migrations/2026_08_20_000000_create_shop_schema.php
?? database/migrations/2026_08_20_220000_add_search_and_auth_support.php
?? database/migrations/2026_08_20_220001_add_webhook_delivery_status.php
?? database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php
?? database/migrations/2026_08_20_220003_add_discount_code_to_carts.php
?? database/seeders/OrganizationSeeder.php
?? database/seeders/ShopSeeder.php
?? database/seeders/StoreDomainSeeder.php
?? database/seeders/StoreSeeder.php
?? database/seeders/StoreSettingsSeeder.php
?? resources/views/components/storefront/
?? resources/views/layouts/admin.blade.php
?? resources/views/layouts/empty.blade.php
?? resources/views/layouts/storefront.blade.php
?? resources/views/livewire/admin/
?? resources/views/livewire/storefront/
?? routes/api.php
?? tests/Feature/CommerceFlowTest.php
?? tests/Feature/SearchAnalyticsWebhookTest.php
?? tests/Feature/Storefront/
?? tests/Unit/PricingEngineTest.php
<?php

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

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

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

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

    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts', ['currency' => 'EUR'])
        ->assertCreated()
        ->json();

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

    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [
        'cart_id' => $cart['id'],
        'email' => 'flow@example.test',
    ])->assertCreated()->json();

    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [
        'shipping_address' => [
            'first_name' => 'Flow',
            'last_name' => 'Tester',
            'address1' => '1 Test Street',
            'city' => 'Berlin',
            'country_code' => 'DE',
            'postal_code' => '10115',
        ],
    ])->assertOk()->assertJsonPath('status', 'addressed');

    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", [
        'shipping_method_id' => $rate->getKey(),
    ])->assertOk()->assertJsonPath('status', 'shipping_selected');

    $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", [
        'payment_method' => 'credit_card',
        'card_number' => '4242424242424242',
    ])->assertOk()->assertJsonPath('order.financial_status', 'paid');

    $order = Order::query()->latest('id')->firstOrFail();

    expect($order->payment_method)->toBe('credit_card')
        ->and($order->financial_status->value)->toBe('paid')
        ->and($order->payments()->firstOrFail()->status)->toBe(PaymentStatus::Captured)
        ->and($order->checkout->status->value)->toBe('completed');
});

test('declined payments release the reservation and do not create an order', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $inventoryBefore = InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved;

    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'declined@example.test'])->json();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Declined', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country_code' => 'DE', 'postal_code' => '10115']]);
    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()]);

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

    expect(InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved)->toBe($inventoryBefore)
        ->and(Order::query()->where('email', 'declined@example.test')->exists())->toBeFalse();
});

test('stale cart versions return a conflict response', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();

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

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

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

    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertCreated();
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'discount-error@example.test'])->assertCreated()->json();

    $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/apply-discount", ['code' => 'MISSING'])
        ->assertUnprocessable()
        ->assertJsonPath('code', 'not_found');
});

test('bank transfer keeps inventory reserved until admin confirmation', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'bank@example.test'])->json();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Bank', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country_code' => 'DE', 'postal_code' => '10115']]);
    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()]);

    $response = $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", ['payment_method' => 'bank_transfer'])->assertOk()->json();
    $order = Order::query()->whereKey($response['order']['id'])->firstOrFail();
    $inventory = InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail();

    expect($order->financial_status->value)->toBe('pending')->and($inventory->quantity_reserved)->toBe(1);

    app(OrderService::class)->confirmPayment($order);
    $inventory = $inventory->refresh();

    expect($order->refresh()->financial_status->value)->toBe('paid')
        ->and($inventory->quantity_on_hand)->toBe(79)
        ->and($inventory->quantity_reserved)->toBe(0);
});


exec

status: completed

input:
const results = await Promise.all([
  tools.exec_command({cmd: "sed -n '1,180p' app/Enums/RefundStatus.php; sed -n '1,180p' app/Enums/DiscountType.php; sed -n '1,180p' app/Enums/DiscountValueType.php; sed -n '1,220p' app/Models/Collection.php; sed -n '1,180p' app/Models/Product.php", workdir: "/Users/wesner/Herd/shop", max_output_tokens: 9000, yield_time_ms: 10000}),
  tools.exec_command({cmd: "sed -n '1,180p' app/Models/Order.php; sed -n '1,220p' app/Services/OrderService.php; sed -n '1,240p' app/Services/FulfillmentService.php", workdir: "/Users/wesner/Herd/shop", max_output_tokens: 13000, yield_time_ms: 10000}),
  tools.exec_command({cmd: "sed -n '1,160p' tests/Unit/PricingEngineTest.php; find tests -type f -maxdepth 4 -print | sort", workdir: "/Users/wesner/Herd/shop", max_output_tokens: 9000, yield_time_ms: 10000}),
  tools.exec_command({cmd: "rg -n -C 4 \"discount|Discount|refund|Refund|merge|address\" specs/05-BUSINESS-LOGIC.md | head -260", workdir: "/Users/wesner/Herd/shop", max_output_tokens: 16000, yield_time_ms: 10000})
]);
for (const r of results) text(r.output);


type: CommandExecution

command: sed -n '1,180p' app/Models/Order.php; sed -n '1,220p' app/Services/OrderService.php; sed -n '1,240p' app/Services/FulfillmentService.php

Order.php

type: read

cmd: sed -n '1,180p' app/Models/Order.php

FulfillmentService.php

status: completed

stdout:
<?php

namespace App\Models;

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'customer_id', 'checkout_id', 'order_number', 'currency', 'status', 'financial_status', 'fulfillment_status', 'payment_method', 'email', 'shipping_address_json', 'billing_address_json', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'placed_at', 'metadata'];

    protected function casts(): array
    {
        return ['status' => OrderStatus::class, 'financial_status' => FinancialStatus::class, 'fulfillment_status' => FulfillmentStatus::class, 'shipping_address_json' => 'array', 'billing_address_json' => 'array', 'placed_at' => 'datetime', 'metadata' => 'array'];
    }

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

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

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

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

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

    public function fulfillments(): HasMany
    {
        return $this->hasMany(Fulfillment::class);
    }
}
<?php

namespace App\Services;

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

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

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

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

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

            foreach ($checkout->cart->lines as $line) {
                $order->lines()->create(['product_id' => $line->variant->product_id, 'variant_id' => $line->variant_id, 'product_title' => $line->variant->product->title, 'title_snapshot' => $line->variant->product->title.' · '.$line->variant->title, 'variant_title' => $line->variant->title, 'sku' => $line->variant->sku, 'sku_snapshot' => $line->variant->sku, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'line_subtotal_amount' => $line->line_subtotal_amount, 'line_total_amount' => $line->line_total_amount]);

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

            $checkout->update(['status' => 'completed']);
            $checkout->cart->update(['status' => 'converted']);
            OrderCreated::dispatch($order);
            $order->load(['lines', 'payments']);

            if ($status === FinancialStatus::Paid) {
                OrderPaid::dispatch($order);
            }

            return $order;
        });
    }

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

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

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

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

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

            OrderCancelled::dispatch($order->refresh());
        });
    }

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

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

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

            $order->payments()->where('status', PaymentStatus::Pending)->update(['status' => PaymentStatus::Captured]);
            OrderPaid::dispatch($order->refresh());
        });
    }
}
<?php

namespace App\Services;

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Events\FulfillmentDelivered;
use App\Events\OrderFulfilled;
use App\Exceptions\FulfillmentGuardException;
use App\Models\Fulfillment;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class FulfillmentService
{
    public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment
    {
        if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) {
            throw new FulfillmentGuardException;
        }

        return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment {
            $order->load(['lines', 'fulfillments.lines']);
            $fulfilledQuantities = $order->fulfillments->flatMap->lines->groupBy('order_line_id')->map(fn ($items): int => (int) $items->sum('quantity'));

            foreach ($lines as $line) {
                $orderLine = $order->lines->firstWhere('id', $line['order_line_id']);
                $quantity = (int) ($line['quantity'] ?? 0);

                if ($orderLine === null || $quantity < 1 || $quantity + (int) ($fulfilledQuantities[$orderLine->id] ?? 0) > $orderLine->quantity) {
                    throw new \InvalidArgumentException('The fulfillment quantity exceeds the unfulfilled quantity.');
                }
            }

            $fulfillment = $order->fulfillments()->create(array_merge($tracking ?? [], ['status' => 'pending']));

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

            $this->refreshOrderStatus($order->refresh());

            return $fulfillment->load('lines.orderLine');
        });
    }

    public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void
    {
        $fulfillment->update(array_merge($tracking ?? [], ['status' => 'shipped', 'shipped_at' => now(), 'fulfilled_at' => now()]));
    }

    public function markAsDelivered(Fulfillment $fulfillment): void
    {
        $fulfillment->update(['status' => 'delivered', 'delivered_at' => now()]);
        $fulfillment->load('order');
        FulfillmentDelivered::dispatch($fulfillment);

        if ($fulfillment->order !== null) {
            $fulfillment->order->update(['status' => 'fulfilled']);
        }
    }

    private function refreshOrderStatus(Order $order): void
    {
        $order->load(['lines', 'fulfillments.lines']);
        $fulfilledQuantities = [];

        foreach ($order->fulfillments as $fulfillment) {
            foreach ($fulfillment->lines as $line) {
                $fulfilledQuantities[$line->order_line_id] = ($fulfilledQuantities[$line->order_line_id] ?? 0) + $line->quantity;
            }
        }

        $fulfilled = collect($order->lines)->every(fn ($line): bool => ($fulfilledQuantities[$line->id] ?? 0) >= $line->quantity);
        $partial = $fulfilledQuantities !== [];
        $order->update(['fulfillment_status' => $fulfilled ? FulfillmentStatus::Fulfilled : ($partial ? FulfillmentStatus::Partial : FulfillmentStatus::Unfulfilled)]);

        if ($fulfilled) {
            OrderFulfilled::dispatch($order->refresh());
        }
    }
}


command: sed -n '1,180p' app/Enums/RefundStatus.php; sed -n '1,180p' app/Enums/DiscountType.php; sed -n '1,180p' app/Enums/DiscountValueType.php; sed -n '1,220p' app/Models/Collection.php; sed -n '1,180p' app/Models/Product.php

Product.php

cmd: sed -n '1,180p' app/Models/Product.php

stdout:
<?php

namespace App\Enums;

enum RefundStatus: string
{
    case Pending = 'pending';
    case Processed = 'processed';
    case Failed = 'failed';
}
<?php

namespace App\Enums;

enum DiscountType: string
{
    case Code = 'code';
    case Automatic = 'automatic';
}
<?php

namespace App\Enums;

enum DiscountValueType: string
{
    case Percent = 'percent';
    case Fixed = 'fixed';
    case FreeShipping = 'free_shipping';
}
<?php

namespace App\Models;

use App\Enums\CollectionStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Collection extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'title', 'handle', 'description', 'status', 'image_url'];

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

    public function products(): BelongsToMany
    {
        return $this->belongsToMany(Product::class, 'collection_products')->withPivot('position')->orderBy('collection_products.position');
    }
}
<?php

namespace App\Models;

use App\Enums\ProductStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Product extends Model
{
    use BelongsToStore, HasFactory;

    protected $fillable = ['store_id', 'title', 'handle', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at', 'sales_count', 'metadata'];

    protected function casts(): array
    {
        return ['status' => ProductStatus::class, 'tags' => 'array', 'metadata' => 'array', 'published_at' => 'datetime'];
    }

    public function variants(): HasMany
    {
        return $this->hasMany(ProductVariant::class)->orderBy('position');
    }

    public function options(): HasMany
    {
        return $this->hasMany(ProductOption::class)->orderBy('position');
    }

    public function media(): HasMany
    {
        return $this->hasMany(ProductMedia::class)->orderBy('position');
    }

    public function collections(): BelongsToMany
    {
        return $this->belongsToMany(Collection::class, 'collection_products')->withPivot('position');
    }

    public function orders(): BelongsToMany
    {
        return $this->belongsToMany(Order::class, 'order_lines');
    }

    public function scopePublished($query): void
    {
        $query->where('status', ProductStatus::Active)->whereNotNull('published_at')->where('published_at', '<=', now());
    }

    public function defaultVariant(): ?ProductVariant
    {
        return $this->variants->firstWhere('is_default', true) ?? $this->variants->first();
    }
}


command: sed -n '1,160p' tests/Unit/PricingEngineTest.php; find tests -type f -maxdepth 4 -print | sort

PricingEngineTest.php

cmd: find tests -type f -maxdepth 4 -print

path: tests

stdout:
<?php

use App\Enums\DiscountValueType;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Models\Checkout;
use App\Models\Discount;
use App\Services\DiscountService;
use App\Services\MockPaymentProvider;
use App\Services\TaxCalculator;

test('tax calculations use integer minor-unit arithmetic', function (): void {
    $calculator = new TaxCalculator;

    expect($calculator->addExclusive(2499, 1900))->toBe(474)
        ->and($calculator->extractInclusive(2973, 1900))->toBe(474);
});

test('discount calculations cap fixed discounts and allocate percentages', function (): void {
    $service = new DiscountService;
    $discount = new Discount(['value_type' => DiscountValueType::Percent, 'value_amount' => 10]);

    $result = $service->calculate($discount, 10000, [
        ['line_id' => 1, 'amount' => 6000],
        ['line_id' => 2, 'amount' => 4000],
    ]);

    expect($result->amount)->toBe(1000)
        ->and($result->allocations)->toBe([1 => 600, 2 => 400]);

    $fixed = new Discount(['value_type' => DiscountValueType::Fixed, 'value_amount' => 5000]);

    expect($service->calculate($fixed, 300, [['line_id' => 1, 'amount' => 300]])->amount)->toBe(300);
});

test('free shipping discounts do not reduce merchandise totals', function (): void {
    $discount = new Discount(['value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0]);
    $result = (new DiscountService)->calculate($discount, 2500, [['line_id' => 1, 'amount' => 2500]]);

    expect($result->amount)->toBe(0)->and($result->freeShipping)->toBeTrue();
});

test('mock payment provider supports the documented magic cards and deferred transfers', function (): void {
    $provider = new MockPaymentProvider;
    $checkout = new Checkout;

    expect($provider->charge($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242'])->status)
        ->toBe(PaymentStatus::Captured)
        ->and($provider->charge($checkout, PaymentMethod::CreditCard, ['card_number' => '4000000000000002'])->status)
        ->toBe(PaymentStatus::Failed)
        ->and($provider->charge($checkout, PaymentMethod::BankTransfer, [])->status)
        ->toBe(PaymentStatus::Pending);
});
tests/Feature/Auth/AuthenticationTest.php
tests/Feature/Auth/EmailVerificationTest.php
tests/Feature/Auth/PasswordConfirmationTest.php
tests/Feature/Auth/PasswordResetTest.php
tests/Feature/Auth/RegistrationTest.php
tests/Feature/Auth/TwoFactorChallengeTest.php
tests/Feature/CommerceFlowTest.php
tests/Feature/DashboardTest.php
tests/Feature/ExampleTest.php
tests/Feature/SearchAnalyticsWebhookTest.php
tests/Feature/Settings/PasswordUpdateTest.php
tests/Feature/Settings/ProfileUpdateTest.php
tests/Feature/Settings/TwoFactorAuthenticationTest.php
tests/Feature/Storefront/CustomerAuthenticationTest.php
tests/Feature/Tenancy/ResolveStoreTest.php
tests/Pest.php
tests/TestCase.php
tests/Unit/ExampleTest.php
tests/Unit/Policies/StorePolicyTest.php
tests/Unit/PricingEngineTest.php
tests/Unit/Tenancy/BelongsToStoreTest.php


command: rg -n -C 4 "discount|Discount|refund|Refund|merge|address" specs/05-BUSINESS-LOGIC.md | head -260

type: search

cmd: rg -n -C 4 'discount|Discount|refund|Refund|merge|address' specs/05-BUSINESS-LOGIC.md

query: discount|Discount|refund|Refund|merge|address

stdout:
8-
9-| Convention | Detail |
10-|---|---|
11-| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |
12:| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |
13-| Rounding | Round each line independently, then sum (prevents rounding drift) |
14-| Database | SQLite - single-writer, WAL mode, no advisory locks needed |
15-| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |
16-| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |
--
76-- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.
77-- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.
78-
79-**Models that must use this trait:**
80:products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
81-
82-### 1.3 Role-Based Access Control
83-
84-**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`
--
90-| Manage store settings | Yes | Yes | No | No |
91-| Manage staff | Yes | Yes | No | No |
92-| Manage products | Yes | Yes | Yes | No |
93-| Manage orders | Yes | Yes | Yes | Yes (read-only) |
94:| Manage discounts | Yes | Yes | Yes | No |
95-| Manage themes | Yes | Yes | No | No |
96-| Manage apps | Yes | Yes | No | No |
97-| View analytics | Yes | Yes | Yes | No |
98-| Manage shipping/tax | Yes | Yes | No | No |
99:| Process refunds | Yes | Yes | No | No |
100-| Create fulfillments | Yes | Yes | Yes | No |
101-
102-**Implementation requirements:**
103-
--
228-    subgraph "Stock Lifecycle"
229-        A[On Hand] -->|Reserve| B[Reserved]
230-        B -->|Commit on payment| C[Deducted]
231-        B -->|Release on expiry| A
232:        C -->|Restock on refund| A
233-    end
234-```
235-
236-**Operation definitions:**
--
239-|---|---|---|---|---|
240-| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
241-| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
242-| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
243:| **Restock** | Refund processed with restock flag | +quantity | No change | None |
244-
245-### 3.4 Concurrency
246-
247-SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.
--
264-**Cart identification:**
265-- Guest users: cart ID stored in session cookie.
266-- Authenticated customers: cart linked via `customer_id` foreign key.
267-
268:The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.
269-
270:**Cart merge on login:**
271-
272-```
273:PROCEDURE mergeGuestCart(guestCart, customerCart):
274-    FOR EACH line IN guestCart.lines:
275-        existingLine = customerCart.findLineByVariant(line.variant_id)
276-        IF existingLine EXISTS:
277-            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
--
296-4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.
297-5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.
298-6. Set `unit_price_amount` from the variant's `price_amount`.
299-7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.
300:8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).
301:9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.
302-10. Increment `cart_version`.
303-
304-**Update quantity:**
305-- Validate new quantity > 0. If 0, remove the line instead.
--
326-### 4.4 Line Amount Calculation
327-
328-```
329-line_subtotal_amount = unit_price_amount * quantity
330:line_discount_amount = 0  (updated by discount engine during checkout)
331:line_total_amount    = line_subtotal_amount - line_discount_amount
332-```
333-
334-All values are integers in minor currency units.
335-
--
356-flowchart TD
357-    A["Step 1: Line Subtotals
358-    FOR EACH line: subtotal = unit_price * quantity"] --> B["Step 2: Cart Subtotal
359-    SUM of all line subtotals"]
360:    B --> C["Step 3: Discount
361:    Apply discount rules (Section 7)
362-    Allocate proportionally across qualifying lines"]
363:    C --> D["Step 4: Discounted Subtotal
364:    subtotal - total_discount"]
365-    D --> E["Step 5: Shipping
366-    Based on selected method and rate rules (Section 9)"]
367-    E --> F["Step 6: Tax
368:    Calculated on discounted line amounts
369-    + shipping if taxable (Section 8)"]
370-    F --> G["Step 7: Total
371:    discounted_subtotal + shipping + tax_total"]
372-```
373-
374-### 5.2 Output Structure
375-
376-**Value Object:** `App\ValueObjects\PricingResult`
377-
378-| Field | Type | Description |
379-|---|---|---|
380:| `subtotal` | integer (cents) | Sum of all line subtotals before discount |
381:| `discount` | integer (cents) | Total discount amount |
382-| `shipping` | integer (cents) | Shipping cost |
383-| `taxLines` | array of TaxLine | Individual tax line items |
384-| `taxTotal` | integer (cents) | Sum of all tax line amounts |
385:| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |
386-| `currency` | string | ISO 4217 currency code |
387-
388-**Value Object:** `App\ValueObjects\TaxLine`
389-
--
394-| `amount` | integer (cents) | Calculated tax amount |
395-
396-### 5.3 Rounding Strategy
397-
398:- Each line's discount allocation, tax, and total are rounded independently.
399-- Totals are computed by summing already-rounded line amounts.
400-- This prevents accumulation of fractional cents across lines.
401:- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).
402-
403-### 5.4 Determinism and Snapshotting
404-
405-- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.
406:- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.
407-- The snapshot structure mirrors the PricingResult value object.
408-
409----
410-
411-## 6. Checkout State Machine
412-
413-### 6.1 States
414-
415:**Enum:** `App\Enums\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`
416-
417-```mermaid
418-stateDiagram-v2
419-    [*] --> started : Checkout created from cart
420:    started --> addressed : Address provided
421:    addressed --> shipping_selected : Shipping method chosen
422-    shipping_selected --> payment_selected : Payment method selected
423-    payment_selected --> completed : Payment confirmed
424-    payment_selected --> expired : Timeout (24h)
425-
426-    started --> expired : Timeout (24h)
427:    addressed --> expired : Timeout (24h)
428-    shipping_selected --> expired : Timeout (24h)
429-
430:    note right of addressed : Triggers pricing recalculation
431-    note right of shipping_selected : Triggers pricing recalculation
432-    note right of payment_selected : Inventory reserved
433-    note right of completed : Order created, inventory committed
434-    note right of expired : Reserved inventory released
--
437-### 6.2 Transitions and Validation
438-
439-**Service:** `App\Services\CheckoutService`
440-
441:#### Transition: started -> addressed
442-
443:**Method:** `setAddress(checkout, addressData)`
444-
445-**Required data:**
446:- `email` - valid email address
447:- `shipping_address` with required fields:
448-
449-| Field | Required | Type | Notes |
450-|---|---|---|---|
451-| `first_name` | Yes | string | |
452-| `last_name` | Yes | string | |
453:| `address1` | Yes | string | |
454:| `address2` | No | string | |
455-| `company` | No | string | |
456-| `city` | Yes | string | |
457-| `province` | No | string | |
458-| `province_code` | No | string | Used for zone matching |
--
460-| `postal_code` | Yes | string | |
461-| `phone` | No | string | |
462-
463-**Actions:**
464:1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).
465:2. Save email and shipping_address_json on the checkout.
466:3. Copy shipping address to billing_address_json by default (can be overridden later).
467:4. Recalculate pricing (shipping zones and tax regions may change based on address).
468:5. Transition status to `addressed`.
469-6. Dispatch `CheckoutAddressed` event.
470-
471:#### Transition: addressed -> shipping_selected
472-
473-**Method:** `setShippingMethod(checkout, shippingRateId)`
474-
475-**Requires:**
476:- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.
477-- **Skip condition:** If no cart lines have `requires_shipping = true` on their variant, skip this step. Set status to `shipping_selected` with shipping_method_id = null and shipping_amount = 0.
478-
479-**Actions:**
480:1. Resolve available shipping zones for the address (see Section 9).
481-2. Verify the selected rate belongs to an applicable zone.
482-3. Save shipping_method_id on the checkout.
483-4. Recalculate pricing with the selected shipping rate.
484-5. Transition status to `shipping_selected`.
--
488-
489-**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method
490-
491-**Requires:**
492:- All previous data is valid (address, shipping method).
493-- Totals have been calculated and stored.
494-- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.
495-
496-**Actions:**
--
535-           paymentStatus = 'pending'
536-           inventoryAction = 'keep_reserved'
537-
538-    5. CREATE order record with:
539:       - All checkout data (addresses, email, totals snapshot)
540-       - payment_method = checkout.payment_method
541-       - status = orderStatus, financial_status = financialStatus
542-       - fulfillment_status = 'unfulfilled'
543-       - placed_at = now
--
546-       CREATE order_line with snapshot data:
547-       - title_snapshot = product title + variant option labels
548-       - sku_snapshot = variant SKU at time of purchase
549-       - unit_price_amount, quantity, total_amount
550:       - tax_lines_json, discount_allocations_json
551-
552-    7. CREATE payment record:
553-       - Link to order, provider = 'mock', method = checkout.payment_method
554-       - provider_payment_id = mock reference ID
--
558-       FOR EACH cart line:
559-           CALL InventoryService.commit(variant, quantity)
560-       // Note: for bank_transfer, inventory stays reserved until admin confirms payment
561-
562:    9. IF checkout has a discount code:
563:       INCREMENT discount's usage_count by 1
564-
565-    10. SET cart status = 'converted'
566-
567-    11. SET checkout status = 'completed'
--
596-| `CheckoutExpired` | Checkout expired due to inactivity |


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Enums;

enum RefundStatus: string
{
    case Pending = 'pending';
    case Processed = 'processed';
    case Failed = 'failed';
}
<?php

namespace App\Enums;

enum DiscountType: string
{
    case Code = 'code';
    case Automatic = 'automatic';
}
<?php

namespace App\Enums;

enum DiscountValueType: string
{
    case Percent = 'percent';
    case Fixed = 'fixed';
    case FreeShipping = 'free_shipping';
}
<?php

namespace App\Models;

use App\Enums\CollectionStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Collection extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'title', 'handle', 'description', 'status', 'image_url'];

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

    public function products(): BelongsToMany
    {
        return $this->belongsToMany(Product::class, 'collection_products')->withPivot('position')->orderBy('collection_products.position');
    }
}
<?php

namespace App\Models;

use App\Enums\ProductStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Product extends Model
{
    use BelongsToStore, HasFactory;

    protected $fillable = ['store_id', 'title', 'handle', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at', 'sales_count', 'metadata'];

    protected function casts(): array
    {
        return ['status' => ProductStatus::class, 'tags' => 'array', 'metadata' => 'array', 'published_at' => 'datetime'];
    }

    public function variants(): HasMany
    {
        return $this->hasMany(ProductVariant::class)->orderBy('position');
    }

    public function options(): HasMany
    {
        return $this->hasMany(ProductOption::class)->orderBy('position');
    }

    public function media(): HasMany
    {
        return $this->hasMany(ProductMedia::class)->orderBy('position');
    }

    public function collections(): BelongsToMany
    {
        return $this->belongsToMany(Collection::class, 'collection_products')->withPivot('position');
    }

    public function orders(): BelongsToMany
    {
        return $this->belongsToMany(Order::class, 'order_lines');
    }

    public function scopePublished($query): void
    {
        $query->where('status', ProductStatus::Active)->whereNotNull('published_at')->where('published_at', '<=', now());
    }

    public function defaultVariant(): ?ProductVariant
    {
        return $this->variants->firstWhere('is_default', true) ?? $this->variants->first();
    }
}

text:
<?php

namespace App\Models;

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'customer_id', 'checkout_id', 'order_number', 'currency', 'status', 'financial_status', 'fulfillment_status', 'payment_method', 'email', 'shipping_address_json', 'billing_address_json', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'placed_at', 'metadata'];

    protected function casts(): array
    {
        return ['status' => OrderStatus::class, 'financial_status' => FinancialStatus::class, 'fulfillment_status' => FulfillmentStatus::class, 'shipping_address_json' => 'array', 'billing_address_json' => 'array', 'placed_at' => 'datetime', 'metadata' => 'array'];
    }

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

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

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

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

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

    public function fulfillments(): HasMany
    {
        return $this->hasMany(Fulfillment::class);
    }
}
<?php

namespace App\Services;

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

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

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

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

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

            foreach ($checkout->cart->lines as $line) {
                $order->lines()->create(['product_id' => $line->variant->product_id, 'variant_id' => $line->variant_id, 'product_title' => $line->variant->product->title, 'title_snapshot' => $line->variant->product->title.' · '.$line->variant->title, 'variant_title' => $line->variant->title, 'sku' => $line->variant->sku, 'sku_snapshot' => $line->variant->sku, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'line_subtotal_amount' => $line->line_subtotal_amount, 'line_total_amount' => $line->line_total_amount]);

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

            $checkout->update(['status' => 'completed']);
            $checkout->cart->update(['status' => 'converted']);
            OrderCreated::dispatch($order);
            $order->load(['lines', 'payments']);

            if ($status === FinancialStatus::Paid) {
                OrderPaid::dispatch($order);
            }

            return $order;
        });
    }

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

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

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

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

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

            OrderCancelled::dispatch($order->refresh());
        });
    }

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

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

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

            $order->payments()->where('status', PaymentStatus::Pending)->update(['status' => PaymentStatus::Captured]);
            OrderPaid::dispatch($order->refresh());
        });
    }
}
<?php

namespace App\Services;

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Events\FulfillmentDelivered;
use App\Events\OrderFulfilled;
use App\Exceptions\FulfillmentGuardException;
use App\Models\Fulfillment;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class FulfillmentService
{
    public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment
    {
        if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) {
            throw new FulfillmentGuardException;
        }

        return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment {
            $order->load(['lines', 'fulfillments.lines']);
            $fulfilledQuantities = $order->fulfillments->flatMap->lines->groupBy('order_line_id')->map(fn ($items): int => (int) $items->sum('quantity'));

            foreach ($lines as $line) {
                $orderLine = $order->lines->firstWhere('id', $line['order_line_id']);
                $quantity = (int) ($line['quantity'] ?? 0);

                if ($orderLine === null || $quantity < 1 || $quantity + (int) ($fulfilledQuantities[$orderLine->id] ?? 0) > $orderLine->quantity) {
                    throw new \InvalidArgumentException('The fulfillment quantity exceeds the unfulfilled quantity.');
                }
            }

            $fulfillment = $order->fulfillments()->create(array_merge($tracking ?? [], ['status' => 'pending']));

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

            $this->refreshOrderStatus($order->refresh());

            return $fulfillment->load('lines.orderLine');
        });
    }

    public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void
    {
        $fulfillment->update(array_merge($tracking ?? [], ['status' => 'shipped', 'shipped_at' => now(), 'fulfilled_at' => now()]));
    }

    public function markAsDelivered(Fulfillment $fulfillment): void
    {
        $fulfillment->update(['status' => 'delivered', 'delivered_at' => now()]);
        $fulfillment->load('order');
        FulfillmentDelivered::dispatch($fulfillment);

        if ($fulfillment->order !== null) {
            $fulfillment->order->update(['status' => 'fulfilled']);
        }
    }

    private function refreshOrderStatus(Order $order): void
    {
        $order->load(['lines', 'fulfillments.lines']);
        $fulfilledQuantities = [];

        foreach ($order->fulfillments as $fulfillment) {
            foreach ($fulfillment->lines as $line) {
                $fulfilledQuantities[$line->order_line_id] = ($fulfilledQuantities[$line->order_line_id] ?? 0) + $line->quantity;
            }
        }

        $fulfilled = collect($order->lines)->every(fn ($line): bool => ($fulfilledQuantities[$line->id] ?? 0) >= $line->quantity);
        $partial = $fulfilledQuantities !== [];
        $order->update(['fulfillment_status' => $fulfilled ? FulfillmentStatus::Fulfilled : ($partial ? FulfillmentStatus::Partial : FulfillmentStatus::Unfulfilled)]);

        if ($fulfilled) {
            OrderFulfilled::dispatch($order->refresh());
        }
    }
}

text:
<?php

use App\Enums\DiscountValueType;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Models\Checkout;
use App\Models\Discount;
use App\Services\DiscountService;
use App\Services\MockPaymentProvider;
use App\Services\TaxCalculator;

test('tax calculations use integer minor-unit arithmetic', function (): void {
    $calculator = new TaxCalculator;

    expect($calculator->addExclusive(2499, 1900))->toBe(474)
        ->and($calculator->extractInclusive(2973, 1900))->toBe(474);
});

test('discount calculations cap fixed discounts and allocate percentages', function (): void {
    $service = new DiscountService;
    $discount = new Discount(['value_type' => DiscountValueType::Percent, 'value_amount' => 10]);

    $result = $service->calculate($discount, 10000, [
        ['line_id' => 1, 'amount' => 6000],
        ['line_id' => 2, 'amount' => 4000],
    ]);

    expect($result->amount)->toBe(1000)
        ->and($result->allocations)->toBe([1 => 600, 2 => 400]);

    $fixed = new Discount(['value_type' => DiscountValueType::Fixed, 'value_amount' => 5000]);

    expect($service->calculate($fixed, 300, [['line_id' => 1, 'amount' => 300]])->amount)->toBe(300);
});

test('free shipping discounts do not reduce merchandise totals', function (): void {
    $discount = new Discount(['value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0]);
    $result = (new DiscountService)->calculate($discount, 2500, [['line_id' => 1, 'amount' => 2500]]);

    expect($result->amount)->toBe(0)->and($result->freeShipping)->toBeTrue();
});

test('mock payment provider supports the documented magic cards and deferred transfers', function (): void {
    $provider = new MockPaymentProvider;
    $checkout = new Checkout;

    expect($provider->charge($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242'])->status)
        ->toBe(PaymentStatus::Captured)
        ->and($provider->charge($checkout, PaymentMethod::CreditCard, ['card_number' => '4000000000000002'])->status)
        ->toBe(PaymentStatus::Failed)
        ->and($provider->charge($checkout, PaymentMethod::BankTransfer, [])->status)
        ->toBe(PaymentStatus::Pending);
});
tests/Feature/Auth/AuthenticationTest.php
tests/Feature/Auth/EmailVerificationTest.php
tests/Feature/Auth/PasswordConfirmationTest.php
tests/Feature/Auth/PasswordResetTest.php
tests/Feature/Auth/RegistrationTest.php
tests/Feature/Auth/TwoFactorChallengeTest.php
tests/Feature/CommerceFlowTest.php
tests/Feature/DashboardTest.php
tests/Feature/ExampleTest.php
tests/Feature/SearchAnalyticsWebhookTest.php
tests/Feature/Settings/PasswordUpdateTest.php
tests/Feature/Settings/ProfileUpdateTest.php
tests/Feature/Settings/TwoFactorAuthenticationTest.php
tests/Feature/Storefront/CustomerAuthenticationTest.php
tests/Feature/Tenancy/ResolveStoreTest.php
tests/Pest.php
tests/TestCase.php
tests/Unit/ExampleTest.php
tests/Unit/Policies/StorePolicyTest.php
tests/Unit/PricingEngineTest.php
tests/Unit/Tenancy/BelongsToStoreTest.php

text:
8-
9-| Convention | Detail |
10-|---|---|
11-| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |
12:| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |
13-| Rounding | Round each line independently, then sum (prevents rounding drift) |
14-| Database | SQLite - single-writer, WAL mode, no advisory locks needed |
15-| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |
16-| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |
--
76-- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.
77-- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.
78-
79-**Models that must use this trait:**
80:products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
81-
82-### 1.3 Role-Based Access Control
83-
84-**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`
--
90-| Manage store settings | Yes | Yes | No | No |
91-| Manage staff | Yes | Yes | No | No |
92-| Manage products | Yes | Yes | Yes | No |
93-| Manage orders | Yes | Yes | Yes | Yes (read-only) |
94:| Manage discounts | Yes | Yes | Yes | No |
95-| Manage themes | Yes | Yes | No | No |
96-| Manage apps | Yes | Yes | No | No |
97-| View analytics | Yes | Yes | Yes | No |
98-| Manage shipping/tax | Yes | Yes | No | No |
99:| Process refunds | Yes | Yes | No | No |
100-| Create fulfillments | Yes | Yes | Yes | No |
101-
102-**Implementation requirements:**
103-
--
228-    subgraph "Stock Lifecycle"
229-        A[On Hand] -->|Reserve| B[Reserved]
230-        B -->|Commit on payment| C[Deducted]
231-        B -->|Release on expiry| A
232:        C -->|Restock on refund| A
233-    end
234-```
235-
236-**Operation definitions:**
--
239-|---|---|---|---|---|
240-| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
241-| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
242-| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
243:| **Restock** | Refund processed with restock flag | +quantity | No change | None |
244-
245-### 3.4 Concurrency
246-
247-SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.
--
264-**Cart identification:**
265-- Guest users: cart ID stored in session cookie.
266-- Authenticated customers: cart linked via `customer_id` foreign key.
267-
268:The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.
269-
270:**Cart merge on login:**
271-
272-```
273:PROCEDURE mergeGuestCart(guestCart, customerCart):
274-    FOR EACH line IN guestCart.lines:
275-        existingLine = customerCart.findLineByVariant(line.variant_id)
276-        IF existingLine EXISTS:
277-            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
--
296-4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.
297-5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.
298-6. Set `unit_price_amount` from the variant's `price_amount`.
299-7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.
300:8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).
301:9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.
302-10. Increment `cart_version`.
303-
304-**Update quantity:**
305-- Validate new quantity > 0. If 0, remove the line instead.
--
326-### 4.4 Line Amount Calculation
327-
328-```
329-line_subtotal_amount = unit_price_amount * quantity
330:line_discount_amount = 0  (updated by discount engine during checkout)
331:line_total_amount    = line_subtotal_amount - line_discount_amount
332-```
333-
334-All values are integers in minor currency units.
335-
--
356-flowchart TD
357-    A["Step 1: Line Subtotals
358-    FOR EACH line: subtotal = unit_price * quantity"] --> B["Step 2: Cart Subtotal
359-    SUM of all line subtotals"]
360:    B --> C["Step 3: Discount
361:    Apply discount rules (Section 7)
362-    Allocate proportionally across qualifying lines"]
363:    C --> D["Step 4: Discounted Subtotal
364:    subtotal - total_discount"]
365-    D --> E["Step 5: Shipping
366-    Based on selected method and rate rules (Section 9)"]
367-    E --> F["Step 6: Tax
368:    Calculated on discounted line amounts
369-    + shipping if taxable (Section 8)"]
370-    F --> G["Step 7: Total
371:    discounted_subtotal + shipping + tax_total"]
372-```
373-
374-### 5.2 Output Structure
375-
376-**Value Object:** `App\ValueObjects\PricingResult`
377-
378-| Field | Type | Description |
379-|---|---|---|
380:| `subtotal` | integer (cents) | Sum of all line subtotals before discount |
381:| `discount` | integer (cents) | Total discount amount |
382-| `shipping` | integer (cents) | Shipping cost |
383-| `taxLines` | array of TaxLine | Individual tax line items |
384-| `taxTotal` | integer (cents) | Sum of all tax line amounts |
385:| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |
386-| `currency` | string | ISO 4217 currency code |
387-
388-**Value Object:** `App\ValueObjects\TaxLine`
389-
--
394-| `amount` | integer (cents) | Calculated tax amount |
395-
396-### 5.3 Rounding Strategy
397-
398:- Each line's discount allocation, tax, and total are rounded independently.
399-- Totals are computed by summing already-rounded line amounts.
400-- This prevents accumulation of fractional cents across lines.
401:- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).
402-
403-### 5.4 Determinism and Snapshotting
404-
405-- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.
406:- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.
407-- The snapshot structure mirrors the PricingResult value object.
408-
409----
410-
411-## 6. Checkout State Machine
412-
413-### 6.1 States
414-
415:**Enum:** `App\Enums\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`
416-
417-```mermaid
418-stateDiagram-v2
419-    [*] --> started : Checkout created from cart
420:    started --> addressed : Address provided
421:    addressed --> shipping_selected : Shipping method chosen
422-    shipping_selected --> payment_selected : Payment method selected
423-    payment_selected --> completed : Payment confirmed
424-    payment_selected --> expired : Timeout (24h)
425-
426-    started --> expired : Timeout (24h)
427:    addressed --> expired : Timeout (24h)
428-    shipping_selected --> expired : Timeout (24h)
429-
430:    note right of addressed : Triggers pricing recalculation
431-    note right of shipping_selected : Triggers pricing recalculation
432-    note right of payment_selected : Inventory reserved
433-    note right of completed : Order created, inventory committed
434-    note right of expired : Reserved inventory released
--
437-### 6.2 Transitions and Validation
438-
439-**Service:** `App\Services\CheckoutService`
440-
441:#### Transition: started -> addressed
442-
443:**Method:** `setAddress(checkout, addressData)`
444-
445-**Required data:**
446:- `email` - valid email address
447:- `shipping_address` with required fields:
448-
449-| Field | Required | Type | Notes |
450-|---|---|---|---|
451-| `first_name` | Yes | string | |
452-| `last_name` | Yes | string | |
453:| `address1` | Yes | string | |
454:| `address2` | No | string | |
455-| `company` | No | string | |
456-| `city` | Yes | string | |
457-| `province` | No | string | |
458-| `province_code` | No | string | Used for zone matching |
--
460-| `postal_code` | Yes | string | |
461-| `phone` | No | string | |
462-
463-**Actions:**
464:1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).
465:2. Save email and shipping_address_json on the checkout.
466:3. Copy shipping address to billing_address_json by default (can be overridden later).
467:4. Recalculate pricing (shipping zones and tax regions may change based on address).
468:5. Transition status to `addressed`.
469-6. Dispatch `CheckoutAddressed` event.
470-
471:#### Transition: addressed -> shipping_selected
472-
473-**Method:** `setShippingMethod(checkout, shippingRateId)`
474-
475-**Requires:**
476:- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.
477-- **Skip condition:** If no cart lines have `requires_shipping = true` on their variant, skip this step. Set status to `shipping_selected` with shipping_method_id = null and shipping_amount = 0.
478-
479-**Actions:**
480:1. Resolve available shipping zones for the address (see Section 9).
481-2. Verify the selected rate belongs to an applicable zone.
482-3. Save shipping_method_id on the checkout.
483-4. Recalculate pricing with the selected shipping rate.
484-5. Transition status to `shipping_selected`.
--
488-
489-**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method
490-
491-**Requires:**
492:- All previous data is valid (address, shipping method).
493-- Totals have been calculated and stored.
494-- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.
495-
496-**Actions:**
--
535-           paymentStatus = 'pending'
536-           inventoryAction = 'keep_reserved'
537-
538-    5. CREATE order record with:
539:       - All checkout data (addresses, email, totals snapshot)
540-       - payment_method = checkout.payment_method
541-       - status = orderStatus, financial_status = financialStatus
542-       - fulfillment_status = 'unfulfilled'
543-       - placed_at = now
--
546-       CREATE order_line with snapshot data:
547-       - title_snapshot = product title + variant option labels
548-       - sku_snapshot = variant SKU at time of purchase
549-       - unit_price_amount, quantity, total_amount
550:       - tax_lines_json, discount_allocations_json
551-
552-    7. CREATE payment record:
553-       - Link to order, provider = 'mock', method = checkout.payment_method
554-       - provider_payment_id = mock reference ID
--
558-       FOR EACH cart line:
559-           CALL InventoryService.commit(variant, quantity)
560-       // Note: for bank_transfer, inventory stays reserved until admin confirms payment
561-
562:    9. IF checkout has a discount code:
563:       INCREMENT discount's usage_count by 1
564-
565-    10. SET cart status = 'converted'
566-
567-    11. SET checkout status = 'completed'
--
596-| `CheckoutExpired` | Checkout expired due to inactivity |


exec

status: completed

input:
const r = await tools.exec_command({cmd: "sed -n '660,820p' specs/05-BUSINESS-LOGIC.md && sed -n '270,325p' specs/05-BUSINESS-LOGIC.md && sed -n '485,575p' specs/05-BUSINESS-LOGIC.md", workdir: "/Users/wesner/Herd/shop", max_output_tokens: 18000, yield_time_ms: 10000});
text(r.output);