status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Payments/MockPaymentProviderTest.php
@@
 <?php

-test('example', function () {
-    expect(true)->toBeTrue();
-});
+use App\Enums\PaymentMethod;
+use App\Enums\PaymentStatus;
+use App\Enums\RefundStatus;
+use App\Models\Checkout;
+use App\Models\Payment;
+use App\Services\Payments\MockPaymentProvider;
+
+test('mock provider handles credit card magic numbers', function (string $number, bool $success, PaymentStatus $status, ?string $errorCode) {
+    $result = app(MockPaymentProvider::class)->charge(
+        new Checkout,
+        PaymentMethod::CreditCard,
+        ['card_number' => $number],
+    );
+
+    expect($result->success)->toBe($success)
+        ->and($result->status)->toBe($status)
+        ->and($result->errorCode)->toBe($errorCode);
+
+    if ($success) {
+        expect($result->referenceId)->toStartWith('mock_payment_');
+    }
+})->with([
+    'captured card' => ['4242 4242 4242 4242', true, PaymentStatus::Captured, null],
+    'declined card' => ['4000 0000 0000 0002', false, PaymentStatus::Failed, 'card_declined'],
+    'insufficient funds' => ['4000 0000 0000 9995', false, PaymentStatus::Failed, 'insufficient_funds'],
+    'other card' => ['4111 1111 1111 1111', true, PaymentStatus::Captured, null],
+]);
+
+test('mock provider captures paypal and leaves bank transfer pending', function () {
+    $provider = app(MockPaymentProvider::class);
+
+    $paypal = $provider->charge(new Checkout, PaymentMethod::Paypal);
+    $bankTransfer = $provider->charge(new Checkout, PaymentMethod::BankTransfer);
+
+    expect($paypal->success)->toBeTrue()
+        ->and($paypal->status)->toBe(PaymentStatus::Captured)
+        ->and($paypal->referenceId)->toStartWith('mock_payment_')
+        ->and($bankTransfer->success)->toBeTrue()
+        ->and($bankTransfer->status)->toBe(PaymentStatus::Pending)
+        ->and($bankTransfer->referenceId)->toStartWith('mock_bank_');
+});
+
+test('mock provider processes valid refunds and rejects invalid amounts', function () {
+    $provider = app(MockPaymentProvider::class);
+
+    $processed = $provider->refund(new Payment, 500);
+    $failed = $provider->refund(new Payment, 0);
+
+    expect($processed->success)->toBeTrue()
+        ->and($processed->status)->toBe(RefundStatus::Processed)
+        ->and($processed->referenceId)->toStartWith('mock_refund_')
+        ->and($failed->success)->toBeFalse()
+        ->and($failed->status)->toBe(RefundStatus::Failed)
+        ->and($failed->errorCode)->toBe('invalid_refund_amount');
+});
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Orders/OrderServiceTest.php
@@
 <?php

-test('example', function () {
-    expect(true)->toBeTrue();
-});
+use App\Enums\CartStatus;
+use App\Enums\CheckoutStatus;
+use App\Enums\FinancialStatus;
+use App\Enums\OrderStatus;
+use App\Enums\PaymentStatus;
+use App\Events\OrderCreated;
+use App\Events\OrderPaid;
+use App\Exceptions\PaymentFailedException;
+use App\Models\Checkout;
+use App\Models\Discount;
+use App\Models\InventoryItem;
+use App\Models\Order;
+use App\Models\Payment;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\ShippingRate;
+use App\Models\ShippingZone;
+use App\Models\Store;
+use App\Models\StoreSettings;
+use App\Models\TaxSettings;
+use App\Services\CartService;
+use App\Services\CheckoutService;
+use App\Services\PricingEngine;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Event;
+
+uses(RefreshDatabase::class);
+
+function orderCompletionStore(int $start = 1001): Store
+{
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    StoreSettings::query()->create([
+        'store_id' => $store->getKey(),
+        'settings_json' => [
+            'order_number_prefix' => '#',
+            'order_number_start' => $start,
+        ],
+    ]);
+
+    TaxSettings::withoutGlobalScopes()->create([
+        'store_id' => $store->getKey(),
+        'mode' => 'manual',
+        'provider' => 'none',
+        'prices_include_tax' => false,
+        'config_json' => ['default_rate_bps' => 0, 'shipping_taxable' => false],
+    ]);
+
+    return $store;
+}
+
+function orderCompletionVariant(Store $store, int $price = 2500, int $stock = 10): ProductVariant
+{
+    $product = Product::factory()
+        ->withDefaultVariant($price)
+        ->create(['store_id' => $store->getKey()]);
+    $variant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->firstOrFail();
+
+    InventoryItem::withoutGlobalScopes()
+        ->where('variant_id', $variant->getKey())
+        ->update([
+            'quantity_on_hand' => $stock,
+            'quantity_reserved' => 0,
+        ]);
+
+    return $variant->refresh();
+}
+
+function orderCompletionShippingRate(Store $store): ShippingRate
+{
+    $zone = ShippingZone::withoutGlobalScopes()->create([
+        'store_id' => $store->getKey(),
+        'name' => 'Germany',
+        'countries_json' => ['DE'],
+        'regions_json' => [],
+    ]);
+
+    return ShippingRate::withoutGlobalScopes()->create([
+        'zone_id' => $zone->getKey(),
+        'name' => 'Standard',
+        'type' => 'flat',
+        'config_json' => ['amount' => 499],
+        'is_active' => true,
+    ]);
+}
+
+/**
+ * @return array{0: Checkout, 1: ProductVariant, 2: \App\Models\Cart}
+ */
+function orderCompletionCheckout(Store $store, string $paymentMethod = 'credit_card', ?string $discountCode = null): array
+{
+    $variant = orderCompletionVariant($store);
+    $rate = orderCompletionShippingRate($store);
+    $cart = app(CartService::class)->create($store);
+
+    app(CartService::class)->addLine($cart, $variant->getKey(), 2);
+
+    $checkout = app(CheckoutService::class)->createFromCart($cart);
+    $checkout = app(CheckoutService::class)->setAddress($checkout, [
+        'email' => 'buyer@example.test',
+        'shipping_address' => [
+            'first_name' => 'Test',
+            'last_name' => 'Buyer',
+            'address1' => 'Main Street 1',
+            'city' => 'Berlin',
+            'country' => 'DE',
+            'postal_code' => '10115',
+        ],
+    ]);
+    $checkout = app(CheckoutService::class)->setShippingMethod($checkout, $rate->getKey());
+
+    if ($discountCode !== null) {
+        $checkout->forceFill(['discount_code' => $discountCode])->save();
+        app(PricingEngine::class)->calculate($checkout);
+    }
+
+    $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, $paymentMethod);
+
+    return [$checkout, $variant, $cart];
+}
+
+test('checkout completion creates a paid order and commits inventory', function () {
+    $store = orderCompletionStore();
+    $discount = Discount::factory()
+        ->fixed(500)
+        ->create([
+            'store_id' => $store->getKey(),
+            'code' => 'SAVE500',
+        ]);
+    [$checkout, $variant, $cart] = orderCompletionCheckout($store, discountCode: 'SAVE500');
+
+    Event::fake();
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+
+    expect($order->order_number)->toBe('#1001')
+        ->and($order->status)->toBe(OrderStatus::Paid)
+        ->and($order->financial_status)->toBe(FinancialStatus::Paid)
+        ->and($order->total_amount)->toBe(4999)
+        ->and($order->lines)->toHaveCount(1)
+        ->and($order->lines->first()->title_snapshot)->toContain($variant->product->title)
+        ->and($order->lines->first()->discount_allocations_json)->toBe([['code' => 'SAVE500', 'amount' => 500]])
+        ->and($order->payments)->toHaveCount(1)
+        ->and($order->payments->first()->status)->toBe(PaymentStatus::Captured)
+        ->and($order->payments->first()->raw_json_encrypted['success'])->toBeTrue()
+        ->and($cart->refresh()->status)->toBe(CartStatus::Converted)
+        ->and($checkout->refresh()->status)->toBe(CheckoutStatus::Completed)
+        ->and($discount->refresh()->usage_count)->toBe(1);
+
+    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();
+
+    expect($inventory->quantity_on_hand)->toBe(8)
+        ->and($inventory->quantity_reserved)->toBe(0);
+
+    Event::assertDispatched(OrderCreated::class, fn (OrderCreated $event): bool => $event->order->is($order));
+    Event::assertDispatched(OrderPaid::class, fn (OrderPaid $event): bool => $event->order->is($order));
+});
+
+test('checkout completion is idempotent for the same checkout', function () {
+    $store = orderCompletionStore();
+    [$checkout, $variant] = orderCompletionCheckout($store);
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+    $sameOrder = app(CheckoutService::class)->completeCheckout($checkout->refresh(), [
+        'card_number' => '4000 0000 0000 0002',
+    ]);
+
+    expect($sameOrder->is($order))->toBeTrue()
+        ->and(Order::withoutGlobalScopes()->count())->toBe(1)
+        ->and(Payment::query()->count())->toBe(1)
+        ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_on_hand)->toBe(8)
+        ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_reserved)->toBe(0);
+});
+
+test('bank transfer completion creates a pending order and keeps inventory reserved', function () {
+    $store = orderCompletionStore();
+    [$checkout, $variant] = orderCompletionCheckout($store, paymentMethod: 'bank_transfer');
+
+    Event::fake();
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout);
+
+    expect($order->status)->toBe(OrderStatus::Pending)
+        ->and($order->financial_status)->toBe(FinancialStatus::Pending)
+        ->and($order->payments->first()->status)->toBe(PaymentStatus::Pending)
+        ->and($checkout->refresh()->status)->toBe(CheckoutStatus::Completed);
+
+    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();
+
+    expect($inventory->quantity_on_hand)->toBe(10)
+        ->and($inventory->quantity_reserved)->toBe(2);
+
+    Event::assertDispatched(OrderCreated::class);
+    Event::assertNotDispatched(OrderPaid::class);
+});
+
+test('payment failures release reserved inventory and allow checkout retry', function () {
+    $store = orderCompletionStore();
+    [$checkout, $variant] = orderCompletionCheckout($store);
+
+    expect(fn () => app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4000 0000 0000 0002',
+    ]))->toThrow(PaymentFailedException::class);
+
+    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();
+
+    expect(Order::withoutGlobalScopes()->count())->toBe(0)
+        ->and($inventory->quantity_on_hand)->toBe(10)
+        ->and($inventory->quantity_reserved)->toBe(0)
+        ->and($checkout->refresh()->status)->toBe(CheckoutStatus::ShippingSelected)
+        ->and($checkout->payment_method)->toBeNull();
+});
+
+test('order numbers increment sequentially per store', function () {
+    $store = orderCompletionStore(start: 5001);
+    [$firstCheckout] = orderCompletionCheckout($store);
+    [$secondCheckout] = orderCompletionCheckout($store);
+
+    $firstOrder = app(CheckoutService::class)->completeCheckout($firstCheckout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+    $secondOrder = app(CheckoutService::class)->completeCheckout($secondCheckout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+
+    expect($firstOrder->order_number)->toBe('#5001')
+        ->and($secondOrder->order_number)->toBe('#5002');
+});
*** End Patch


output:
apply_patch verification failed: Failed to find expected lines in /Users/fabianwesner/Herd/shop/tests/Feature/Payments/MockPaymentProviderTest.php:
<?php

test('example', function () {
    expect(true)->toBeTrue();
});


cmd: sed -n '1,80p' tests/Feature/Payments/MockPaymentProviderTest.php


cmd: sed -n '1,80p' tests/Feature/Orders/OrderServiceTest.php


command: sed -n '1,80p' tests/Feature/Payments/MockPaymentProviderTest.php

MockPaymentProviderTest.php

type: read

aggregated_output:
<?php

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});

status: completed


command: sed -n '1,80p' tests/Feature/Orders/OrderServiceTest.php


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

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});


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

test('example', function () {
    $response = $this->get('/');

    $response->assertStatus(200);
});


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Payments/MockPaymentProviderTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
-
-    $response->assertStatus(200);
-});
+use App\Enums\PaymentMethod;
+use App\Enums\PaymentStatus;
+use App\Enums\RefundStatus;
+use App\Models\Checkout;
+use App\Models\Payment;
+use App\Services\Payments\MockPaymentProvider;
+
+test('mock provider handles credit card magic numbers', function (string $number, bool $success, PaymentStatus $status, ?string $errorCode) {
+    $result = app(MockPaymentProvider::class)->charge(
+        new Checkout,
+        PaymentMethod::CreditCard,
+        ['card_number' => $number],
+    );
+
+    expect($result->success)->toBe($success)
+        ->and($result->status)->toBe($status)
+        ->and($result->errorCode)->toBe($errorCode);
+
+    if ($success) {
+        expect($result->referenceId)->toStartWith('mock_payment_');
+    }
+})->with([
+    'captured card' => ['4242 4242 4242 4242', true, PaymentStatus::Captured, null],
+    'declined card' => ['4000 0000 0000 0002', false, PaymentStatus::Failed, 'card_declined'],
+    'insufficient funds' => ['4000 0000 0000 9995', false, PaymentStatus::Failed, 'insufficient_funds'],
+    'other card' => ['4111 1111 1111 1111', true, PaymentStatus::Captured, null],
+]);
+
+test('mock provider captures paypal and leaves bank transfer pending', function () {
+    $provider = app(MockPaymentProvider::class);
+
+    $paypal = $provider->charge(new Checkout, PaymentMethod::Paypal);
+    $bankTransfer = $provider->charge(new Checkout, PaymentMethod::BankTransfer);
+
+    expect($paypal->success)->toBeTrue()
+        ->and($paypal->status)->toBe(PaymentStatus::Captured)
+        ->and($paypal->referenceId)->toStartWith('mock_payment_')
+        ->and($bankTransfer->success)->toBeTrue()
+        ->and($bankTransfer->status)->toBe(PaymentStatus::Pending)
+        ->and($bankTransfer->referenceId)->toStartWith('mock_bank_');
+});
+
+test('mock provider processes valid refunds and rejects invalid amounts', function () {
+    $provider = app(MockPaymentProvider::class);
+
+    $processed = $provider->refund(new Payment, 500);
+    $failed = $provider->refund(new Payment, 0);
+
+    expect($processed->success)->toBeTrue()
+        ->and($processed->status)->toBe(RefundStatus::Processed)
+        ->and($processed->referenceId)->toStartWith('mock_refund_')
+        ->and($failed->success)->toBeFalse()
+        ->and($failed->status)->toBe(RefundStatus::Failed)
+        ->and($failed->errorCode)->toBe('invalid_refund_amount');
+});
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Orders/OrderServiceTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
-
-    $response->assertStatus(200);
-});
+use App\Enums\CartStatus;
+use App\Enums\CheckoutStatus;
+use App\Enums\FinancialStatus;
+use App\Enums\OrderStatus;
+use App\Enums\PaymentStatus;
+use App\Events\OrderCreated;
+use App\Events\OrderPaid;
+use App\Exceptions\PaymentFailedException;
+use App\Models\Checkout;
+use App\Models\Discount;
+use App\Models\InventoryItem;
+use App\Models\Order;
+use App\Models\Payment;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\ShippingRate;
+use App\Models\ShippingZone;
+use App\Models\Store;
+use App\Models\StoreSettings;
+use App\Models\TaxSettings;
+use App\Services\CartService;
+use App\Services\CheckoutService;
+use App\Services\PricingEngine;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Event;
+
+uses(RefreshDatabase::class);
+
+function orderCompletionStore(int $start = 1001): Store
+{
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    StoreSettings::query()->create([
+        'store_id' => $store->getKey(),
+        'settings_json' => [
+            'order_number_prefix' => '#',
+            'order_number_start' => $start,
+        ],
+    ]);
+
+    TaxSettings::withoutGlobalScopes()->create([
+        'store_id' => $store->getKey(),
+        'mode' => 'manual',
+        'provider' => 'none',
+        'prices_include_tax' => false,
+        'config_json' => ['default_rate_bps' => 0, 'shipping_taxable' => false],
+    ]);
+
+    return $store;
+}
+
+function orderCompletionVariant(Store $store, int $price = 2500, int $stock = 10): ProductVariant
+{
+    $product = Product::factory()
+        ->withDefaultVariant($price)
+        ->create(['store_id' => $store->getKey()]);
+    $variant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->firstOrFail();
+
+    InventoryItem::withoutGlobalScopes()
+        ->where('variant_id', $variant->getKey())
+        ->update([
+            'quantity_on_hand' => $stock,
+            'quantity_reserved' => 0,
+        ]);
+
+    return $variant->refresh();
+}
+
+function orderCompletionShippingRate(Store $store): ShippingRate
+{
+    $zone = ShippingZone::withoutGlobalScopes()->create([
+        'store_id' => $store->getKey(),
+        'name' => 'Germany',
+        'countries_json' => ['DE'],
+        'regions_json' => [],
+    ]);
+
+    return ShippingRate::withoutGlobalScopes()->create([
+        'zone_id' => $zone->getKey(),
+        'name' => 'Standard',
+        'type' => 'flat',
+        'config_json' => ['amount' => 499],
+        'is_active' => true,
+    ]);
+}
+
+/**
+ * @return array{0: Checkout, 1: ProductVariant, 2: \App\Models\Cart}
+ */
+function orderCompletionCheckout(Store $store, string $paymentMethod = 'credit_card', ?string $discountCode = null): array
+{
+    $variant = orderCompletionVariant($store);
+    $rate = orderCompletionShippingRate($store);
+    $cart = app(CartService::class)->create($store);
+
+    app(CartService::class)->addLine($cart, $variant->getKey(), 2);
+
+    $checkout = app(CheckoutService::class)->createFromCart($cart);
+    $checkout = app(CheckoutService::class)->setAddress($checkout, [
+        'email' => 'buyer@example.test',
+        'shipping_address' => [
+            'first_name' => 'Test',
+            'last_name' => 'Buyer',
+            'address1' => 'Main Street 1',
+            'city' => 'Berlin',
+            'country' => 'DE',
+            'postal_code' => '10115',
+        ],
+    ]);
+    $checkout = app(CheckoutService::class)->setShippingMethod($checkout, $rate->getKey());
+
+    if ($discountCode !== null) {
+        $checkout->forceFill(['discount_code' => $discountCode])->save();
+        app(PricingEngine::class)->calculate($checkout);
+    }
+
+    $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, $paymentMethod);
+
+    return [$checkout, $variant, $cart];
+}
+
+test('checkout completion creates a paid order and commits inventory', function () {
+    $store = orderCompletionStore();
+    $discount = Discount::factory()
+        ->fixed(500)
+        ->create([
+            'store_id' => $store->getKey(),
+            'code' => 'SAVE500',
+        ]);
+    [$checkout, $variant, $cart] = orderCompletionCheckout($store, discountCode: 'SAVE500');
+
+    Event::fake();
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+
+    expect($order->order_number)->toBe('#1001')
+        ->and($order->status)->toBe(OrderStatus::Paid)
+        ->and($order->financial_status)->toBe(FinancialStatus::Paid)
+        ->and($order->total_amount)->toBe(4999)
+        ->and($order->lines)->toHaveCount(1)
+        ->and($order->lines->first()->title_snapshot)->toContain($variant->product->title)
+        ->and($order->lines->first()->discount_allocations_json)->toBe([['code' => 'SAVE500', 'amount' => 500]])
+        ->and($order->payments)->toHaveCount(1)
+        ->and($order->payments->first()->status)->toBe(PaymentStatus::Captured)
+        ->and($order->payments->first()->raw_json_encrypted['success'])->toBeTrue()
+        ->and($cart->refresh()->status)->toBe(CartStatus::Converted)
+        ->and($checkout->refresh()->status)->toBe(CheckoutStatus::Completed)
+        ->and($discount->refresh()->usage_count)->toBe(1);
+
+    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();
+
+    expect($inventory->quantity_on_hand)->toBe(8)
+        ->and($inventory->quantity_reserved)->toBe(0);
+
+    Event::assertDispatched(OrderCreated::class, fn (OrderCreated $event): bool => $event->order->is($order));
+    Event::assertDispatched(OrderPaid::class, fn (OrderPaid $event): bool => $event->order->is($order));
+});
+
+test('checkout completion is idempotent for the same checkout', function () {
+    $store = orderCompletionStore();
+    [$checkout, $variant] = orderCompletionCheckout($store);
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+    $sameOrder = app(CheckoutService::class)->completeCheckout($checkout->refresh(), [
+        'card_number' => '4000 0000 0000 0002',
+    ]);
+
+    expect($sameOrder->is($order))->toBeTrue()
+        ->and(Order::withoutGlobalScopes()->count())->toBe(1)
+        ->and(Payment::query()->count())->toBe(1)
+        ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_on_hand)->toBe(8)
+        ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_reserved)->toBe(0);
+});
+
+test('bank transfer completion creates a pending order and keeps inventory reserved', function () {
+    $store = orderCompletionStore();
+    [$checkout, $variant] = orderCompletionCheckout($store, paymentMethod: 'bank_transfer');
+
+    Event::fake();
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout);
+
+    expect($order->status)->toBe(OrderStatus::Pending)
+        ->and($order->financial_status)->toBe(FinancialStatus::Pending)
+        ->and($order->payments->first()->status)->toBe(PaymentStatus::Pending)
+        ->and($checkout->refresh()->status)->toBe(CheckoutStatus::Completed);
+
+    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();
+
+    expect($inventory->quantity_on_hand)->toBe(10)
+        ->and($inventory->quantity_reserved)->toBe(2);
+
+    Event::assertDispatched(OrderCreated::class);
+    Event::assertNotDispatched(OrderPaid::class);
+});
+
+test('payment failures release reserved inventory and allow checkout retry', function () {
+    $store = orderCompletionStore();
+    [$checkout, $variant] = orderCompletionCheckout($store);
+
+    expect(fn () => app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4000 0000 0000 0002',
+    ]))->toThrow(PaymentFailedException::class);
+
+    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();
+
+    expect(Order::withoutGlobalScopes()->count())->toBe(0)
+        ->and($inventory->quantity_on_hand)->toBe(10)
+        ->and($inventory->quantity_reserved)->toBe(0)
+        ->and($checkout->refresh()->status)->toBe(CheckoutStatus::ShippingSelected)
+        ->and($checkout->payment_method)->toBeNull();
+});
+
+test('order numbers increment sequentially per store', function () {
+    $store = orderCompletionStore(start: 5001);
+    [$firstCheckout] = orderCompletionCheckout($store);
+    [$secondCheckout] = orderCompletionCheckout($store);
+
+    $firstOrder = app(CheckoutService::class)->completeCheckout($firstCheckout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+    $secondOrder = app(CheckoutService::class)->completeCheckout($secondCheckout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+
+    expect($firstOrder->order_number)->toBe('#5001')
+        ->and($secondOrder->order_number)->toBe('#5002');
+});
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/tests/Feature/Payments/MockPaymentProviderTest.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Orders/OrderServiceTest.php

type: update

unified_diff:
@@ -2,6 +2,236 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\CartStatus;
+use App\Enums\CheckoutStatus;
+use App\Enums\FinancialStatus;
+use App\Enums\OrderStatus;
+use App\Enums\PaymentStatus;
+use App\Events\OrderCreated;
+use App\Events\OrderPaid;
+use App\Exceptions\PaymentFailedException;
+use App\Models\Checkout;
+use App\Models\Discount;
+use App\Models\InventoryItem;
+use App\Models\Order;
+use App\Models\Payment;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\ShippingRate;
+use App\Models\ShippingZone;
+use App\Models\Store;
+use App\Models\StoreSettings;
+use App\Models\TaxSettings;
+use App\Services\CartService;
+use App\Services\CheckoutService;
+use App\Services\PricingEngine;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Event;
+
+uses(RefreshDatabase::class);
+
+function orderCompletionStore(int $start = 1001): Store
+{
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    StoreSettings::query()->create([
+        'store_id' => $store->getKey(),
+        'settings_json' => [
+            'order_number_prefix' => '#',
+            'order_number_start' => $start,
+        ],
+    ]);
+
+    TaxSettings::withoutGlobalScopes()->create([
+        'store_id' => $store->getKey(),
+        'mode' => 'manual',
+        'provider' => 'none',
+        'prices_include_tax' => false,
+        'config_json' => ['default_rate_bps' => 0, 'shipping_taxable' => false],
+    ]);
+
+    return $store;
+}
+
+function orderCompletionVariant(Store $store, int $price = 2500, int $stock = 10): ProductVariant
+{
+    $product = Product::factory()
+        ->withDefaultVariant($price)
+        ->create(['store_id' => $store->getKey()]);
+    $variant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->firstOrFail();
+
+    InventoryItem::withoutGlobalScopes()
+        ->where('variant_id', $variant->getKey())
+        ->update([
+            'quantity_on_hand' => $stock,
+            'quantity_reserved' => 0,
+        ]);
+
+    return $variant->refresh();
+}
+
+function orderCompletionShippingRate(Store $store): ShippingRate
+{
+    $zone = ShippingZone::withoutGlobalScopes()->create([
+        'store_id' => $store->getKey(),
+        'name' => 'Germany',
+        'countries_json' => ['DE'],
+        'regions_json' => [],
+    ]);
+
+    return ShippingRate::withoutGlobalScopes()->create([
+        'zone_id' => $zone->getKey(),
+        'name' => 'Standard',
+        'type' => 'flat',
+        'config_json' => ['amount' => 499],
+        'is_active' => true,
+    ]);
+}
+
+/**
+ * @return array{0: Checkout, 1: ProductVariant, 2: \App\Models\Cart}
+ */
+function orderCompletionCheckout(Store $store, string $paymentMethod = 'credit_card', ?string $discountCode = null): array
+{
+    $variant = orderCompletionVariant($store);
+    $rate = orderCompletionShippingRate($store);
+    $cart = app(CartService::class)->create($store);
+
+    app(CartService::class)->addLine($cart, $variant->getKey(), 2);
+
+    $checkout = app(CheckoutService::class)->createFromCart($cart);
+    $checkout = app(CheckoutService::class)->setAddress($checkout, [
+        'email' => 'buyer@example.test',
+        'shipping_address' => [
+            'first_name' => 'Test',
+            'last_name' => 'Buyer',
+            'address1' => 'Main Street 1',
+            'city' => 'Berlin',
+            'country' => 'DE',
+            'postal_code' => '10115',
+        ],
+    ]);
+    $checkout = app(CheckoutService::class)->setShippingMethod($checkout, $rate->getKey());
+
+    if ($discountCode !== null) {
+        $checkout->forceFill(['discount_code' => $discountCode])->save();
+        app(PricingEngine::class)->calculate($checkout);
+    }
+
+    $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, $paymentMethod);
+
+    return [$checkout, $variant, $cart];
+}
+
+test('checkout completion creates a paid order and commits inventory', function () {
+    $store = orderCompletionStore();
+    $discount = Discount::factory()
+        ->fixed(500)
+        ->create([
+            'store_id' => $store->getKey(),
+            'code' => 'SAVE500',
+        ]);
+    [$checkout, $variant, $cart] = orderCompletionCheckout($store, discountCode: 'SAVE500');
+
+    Event::fake();
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+
+    expect($order->order_number)->toBe('#1001')
+        ->and($order->status)->toBe(OrderStatus::Paid)
+        ->and($order->financial_status)->toBe(FinancialStatus::Paid)
+        ->and($order->total_amount)->toBe(4999)
+        ->and($order->lines)->toHaveCount(1)
+        ->and($order->lines->first()->title_snapshot)->toContain($variant->product->title)
+        ->and($order->lines->first()->discount_allocations_json)->toBe([['code' => 'SAVE500', 'amount' => 500]])
+        ->and($order->payments)->toHaveCount(1)
+        ->and($order->payments->first()->status)->toBe(PaymentStatus::Captured)
+        ->and($order->payments->first()->raw_json_encrypted['success'])->toBeTrue()
+        ->and($cart->refresh()->status)->toBe(CartStatus::Converted)
+        ->and($checkout->refresh()->status)->toBe(CheckoutStatus::Completed)
+        ->and($discount->refresh()->usage_count)->toBe(1);
+
+    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();
+
+    expect($inventory->quantity_on_hand)->toBe(8)
+        ->and($inventory->quantity_reserved)->toBe(0);
+
+    Event::assertDispatched(OrderCreated::class, fn (OrderCreated $event): bool => $event->order->is($order));
+    Event::assertDispatched(OrderPaid::class, fn (OrderPaid $event): bool => $event->order->is($order));
+});
+
+test('checkout completion is idempotent for the same checkout', function () {
+    $store = orderCompletionStore();
+    [$checkout, $variant] = orderCompletionCheckout($store);
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+    $sameOrder = app(CheckoutService::class)->completeCheckout($checkout->refresh(), [
+        'card_number' => '4000 0000 0000 0002',
+    ]);
+
+    expect($sameOrder->is($order))->toBeTrue()
+        ->and(Order::withoutGlobalScopes()->count())->toBe(1)
+        ->and(Payment::query()->count())->toBe(1)
+        ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_on_hand)->toBe(8)
+        ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_reserved)->toBe(0);
+});
+
+test('bank transfer completion creates a pending order and keeps inventory reserved', function () {
+    $store = orderCompletionStore();
+    [$checkout, $variant] = orderCompletionCheckout($store, paymentMethod: 'bank_transfer');
+
+    Event::fake();
+
+    $order = app(CheckoutService::class)->completeCheckout($checkout);
+
+    expect($order->status)->toBe(OrderStatus::Pending)
+        ->and($order->financial_status)->toBe(FinancialStatus::Pending)
+        ->and($order->payments->first()->status)->toBe(PaymentStatus::Pending)
+        ->and($checkout->refresh()->status)->toBe(CheckoutStatus::Completed);
+
+    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();

-    $response->assertStatus(200);
+    expect($inventory->quantity_on_hand)->toBe(10)
+        ->and($inventory->quantity_reserved)->toBe(2);
+
+    Event::assertDispatched(OrderCreated::class);
+    Event::assertNotDispatched(OrderPaid::class);
+});
+
+test('payment failures release reserved inventory and allow checkout retry', function () {
+    $store = orderCompletionStore();
+    [$checkout, $variant] = orderCompletionCheckout($store);
+
+    expect(fn () => app(CheckoutService::class)->completeCheckout($checkout, [
+        'card_number' => '4000 0000 0000 0002',
+    ]))->toThrow(PaymentFailedException::class);
+
+    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();
+
+    expect(Order::withoutGlobalScopes()->count())->toBe(0)
+        ->and($inventory->quantity_on_hand)->toBe(10)
+        ->and($inventory->quantity_reserved)->toBe(0)
+        ->and($checkout->refresh()->status)->toBe(CheckoutStatus::ShippingSelected)
+        ->and($checkout->payment_method)->toBeNull();
+});
+
+test('order numbers increment sequentially per store', function () {
+    $store = orderCompletionStore(start: 5001);
+    [$firstCheckout] = orderCompletionCheckout($store);
+    [$secondCheckout] = orderCompletionCheckout($store);
+
+    $firstOrder = app(CheckoutService::class)->completeCheckout($firstCheckout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+    $secondOrder = app(CheckoutService::class)->completeCheckout($secondCheckout, [
+        'card_number' => '4242 4242 4242 4242',
+    ]);
+
+    expect($firstOrder->order_number)->toBe('#5001')
+        ->and($secondOrder->order_number)->toBe('#5002');
 });

unified_diff:
@@ -2,6 +2,56 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\PaymentMethod;
+use App\Enums\PaymentStatus;
+use App\Enums\RefundStatus;
+use App\Models\Checkout;
+use App\Models\Payment;
+use App\Services\Payments\MockPaymentProvider;
+
+test('mock provider handles credit card magic numbers', function (string $number, bool $success, PaymentStatus $status, ?string $errorCode) {
+    $result = app(MockPaymentProvider::class)->charge(
+        new Checkout,
+        PaymentMethod::CreditCard,
+        ['card_number' => $number],
+    );
+
+    expect($result->success)->toBe($success)
+        ->and($result->status)->toBe($status)
+        ->and($result->errorCode)->toBe($errorCode);
+
+    if ($success) {
+        expect($result->referenceId)->toStartWith('mock_payment_');
+    }
+})->with([
+    'captured card' => ['4242 4242 4242 4242', true, PaymentStatus::Captured, null],
+    'declined card' => ['4000 0000 0000 0002', false, PaymentStatus::Failed, 'card_declined'],
+    'insufficient funds' => ['4000 0000 0000 9995', false, PaymentStatus::Failed, 'insufficient_funds'],
+    'other card' => ['4111 1111 1111 1111', true, PaymentStatus::Captured, null],
+]);
+
+test('mock provider captures paypal and leaves bank transfer pending', function () {
+    $provider = app(MockPaymentProvider::class);
+
+    $paypal = $provider->charge(new Checkout, PaymentMethod::Paypal);
+    $bankTransfer = $provider->charge(new Checkout, PaymentMethod::BankTransfer);
+
+    expect($paypal->success)->toBeTrue()
+        ->and($paypal->status)->toBe(PaymentStatus::Captured)
+        ->and($paypal->referenceId)->toStartWith('mock_payment_')
+        ->and($bankTransfer->success)->toBeTrue()
+        ->and($bankTransfer->status)->toBe(PaymentStatus::Pending)
+        ->and($bankTransfer->referenceId)->toStartWith('mock_bank_');
+});
+
+test('mock provider processes valid refunds and rejects invalid amounts', function () {
+    $provider = app(MockPaymentProvider::class);
+
+    $processed = $provider->refund(new Payment, 500);
+    $failed = $provider->refund(new Payment, 0);

-    $response->assertStatus(200);
+    expect($processed->success)->toBeTrue()
+        ->and($processed->status)->toBe(RefundStatus::Processed)
+        ->and($processed->referenceId)->toStartWith('mock_refund_')
+        ->and($failed->success)->toBeFalse()
+        ->and($failed->status)->toBe(RefundStatus::Failed)
+        ->and($failed->errorCode)->toBe('invalid_refund_amount');
 });


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/tests/Feature/Payments/MockPaymentProviderTest.php\nM /Users/fabianwesner/Herd/shop/tests/Feature/Orders/OrderServiceTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: The tests cover the critical money-flow behavior: magic card outcomes, bank transfer pending flow, idempotent checkout completion, failed-payment release, discount usage, and sequential order numbers. I’m formatting PHP now, then I’ll run the focused suites.

phase: commentary


type: message

role: assistant

text: The tests cover the critical money-flow behavior: magic card outcomes, bank transfer pending flow, idempotent checkout completion, failed-payment release, discount usage, and sequential order numbers. I’m formatting PHP now, then I’ll run the focused suites.


output:
Chunk ID: cb3b6f
Wall time: 0.9033 seconds
Process exited with code 0
Original token count: 185
Output:
{"result":"fixed","files":[{"path":"database\/seeders\/FulfillmentSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/PaymentSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/OrderLineSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/RefundSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/FulfillmentLineSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/CustomerAddressSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/OrderSeeder.php","fixers":["no_unused_imports"]},{"path":"app\/Providers\/AppServiceProvider.php","fixers":["unary_operator_spaces","not_operator_with_successor_space","ordered_imports"]}]}


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

type: unknown

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

result: fixed


cmd: php artisan test --compact tests/Feature/Payments tests/Feature/Orders


output:
Chunk ID: 078f04
Wall time: 0.7770 seconds
Process exited with code 2
Original token count: 224
Output:

  ..........⨯
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Orders\OrderSer…  InvalidCheckoutTransitionException
  Selected shipping rate is not available for this address.

  at app/Exceptions/InvalidCheckoutTransitionException.php:11
      7▕ class InvalidCheckoutTransitionException extends RuntimeException
      8▕ {
      9▕     public static function because(string $reason): self
     10▕     {
  ➜  11▕         return new self($reason);
     12▕     }
     13▕ }
     14▕

  1   app/Exceptions/InvalidCheckoutTransitionException.php:11
  2   app/Services/CheckoutService.php:112

  Tests:    1 failed, 10 passed (62 assertions)
  Duration: 0.51s


command: php artisan test --compact tests/Feature/Payments tests/Feature/Orders

aggregated_output:
  ..........⨯
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Orders\OrderSer…  InvalidCheckoutTransitionException
  Selected shipping rate is not available for this address.

  at app/Exceptions/InvalidCheckoutTransitionException.php:11
      7▕ class InvalidCheckoutTransitionException extends RuntimeException
      8▕ {
      9▕     public static function because(string $reason): self
     10▕     {
  ➜  11▕         return new self($reason);
     12▕     }
     13▕ }
     14▕

  1   app/Exceptions/InvalidCheckoutTransitionException.php:11
  2   app/Services/CheckoutService.php:112

  Tests:    1 failed, 10 passed (62 assertions)
  Duration: 0.51s

status: failed


cmd: sed -n '1,200p' database/migrations/*create_shipping_zones_table.php && sed -n '1,200p' app/Services/ShippingCalculator.php


command: sed -n '1,200p' database/migrations/*create_shipping_zones_table.php && sed -n '1,200p' app/Services/ShippingCalculator.php

cmd: sed -n '1,200p' 'database/migrations/*create_shipping_zones_table.php'

ShippingCalculator.php

aggregated_output:
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('shipping_zones', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->text('countries_json')->default('[]');
            $table->text('regions_json')->default('[]');

            $table->index('store_id');
        });
    }

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

namespace App\Services;

use App\Enums\ShippingRateType;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use Illuminate\Support\Collection;

class ShippingCalculator
{
    /**
     * @param  array<string, mixed>  $address
     * @return Collection<int, ShippingRate>
     */
    public function getAvailableRates(Store $store, array $address): Collection
    {
        $zone = $this->matchingZone($store, $address);

        if (! $zone instanceof ShippingZone) {
            return collect();
        }

        return ShippingRate::withoutGlobalScopes()
            ->where('zone_id', $zone->getKey())
            ->where('is_active', true)
            ->orderBy('id')
            ->get();
    }

    /**
     * @param  array<string, mixed>  $address
     */
    public function matchingZone(Store $store, array $address): ?ShippingZone
    {
        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));
        $region = strtoupper((string) data_get($address, 'province_code'));
        $bestZone = null;
        $bestSpecificity = -1;

        ShippingZone::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->orderBy('id')
            ->get()
            ->each(function (ShippingZone $zone) use ($country, $region, &$bestZone, &$bestSpecificity): void {
                $countries = collect($zone->countries_json)->map(fn (string $code): string => strtoupper($code));
                $regions = collect($zone->regions_json)->map(fn (string $code): string => strtoupper($code));

                if (! $countries->contains($country)) {
                    return;
                }

                $specificity = $regions->contains($region) ? 2 : 1;

                if ($specificity > $bestSpecificity) {
                    $bestZone = $zone;
                    $bestSpecificity = $specificity;
                }
            });

        return $bestZone;
    }

    public function calculate(ShippingRate $rate, Cart $cart): ?int
    {
        if (! $this->requiresShipping($cart)) {
            return 0;
        }

        return match ($rate->type) {
            ShippingRateType::Flat => (int) data_get($rate->config_json, 'amount', 0),
            ShippingRateType::Weight => $this->weightRate($rate, $cart),
            ShippingRateType::Price => $this->priceRate($rate, $cart),
            ShippingRateType::Carrier => (int) data_get($rate->config_json, 'amount', 1299),
        };
    }

    public function requiresShipping(Cart $cart): bool
    {
        return $this->physicalLines($cart)->isNotEmpty();
    }

    private function weightRate(ShippingRate $rate, Cart $cart): ?int
    {
        $totalWeight = $this->physicalLines($cart)
            ->sum(function (CartLine $line): int {
                $variant = $this->variant($line);

                return ($variant?->weight_g ?? 0) * $line->quantity;
            });

        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
            $minimum = (int) data_get($range, 'min_g', 0);
            $maximum = data_get($range, 'max_g');

            if ($totalWeight >= $minimum && ($maximum === null || $totalWeight <= (int) $maximum)) {
                return (int) data_get($range, 'amount', 0);
            }
        }

        return null;
    }

    private function priceRate(ShippingRate $rate, Cart $cart): ?int
    {
        $subtotal = CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->sum('line_subtotal_amount');

        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
            $minimum = (int) data_get($range, 'min_amount', 0);
            $maximum = data_get($range, 'max_amount');

            if ($subtotal >= $minimum && ($maximum === null || $subtotal <= (int) $maximum)) {
                return (int) data_get($range, 'amount', 0);
            }
        }

        return null;
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function physicalLines(Cart $cart): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->get()
            ->filter(function (CartLine $line): bool {
                return (bool) $this->variant($line)?->requires_shipping;
            });
    }

    private function variant(CartLine $line): ?ProductVariant
    {
        return ProductVariant::withoutGlobalScopes()
            ->whereKey($line->variant_id)
            ->first();
    }
}


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

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('shipping_zones', function (Blueprint $table) {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->text('countries_json')->default('[]');
            $table->text('regions_json')->default('[]');

            $table->index('store_id');
        });
    }

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

namespace App\Services;

use App\Enums\ShippingRateType;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use Illuminate\Support\Collection;

class ShippingCalculator
{
    /**
     * @param  array<string, mixed>  $address
     * @return Collection<int, ShippingRate>
     */
    public function getAvailableRates(Store $store, array $address): Collection
    {
        $zone = $this->matchingZone($store, $address);

        if (! $zone instanceof ShippingZone) {
            return collect();
        }

        return ShippingRate::withoutGlobalScopes()
            ->where('zone_id', $zone->getKey())
            ->where('is_active', true)
            ->orderBy('id')
            ->get();
    }

    /**
     * @param  array<string, mixed>  $address
     */
    public function matchingZone(Store $store, array $address): ?ShippingZone
    {
        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));
        $region = strtoupper((string) data_get($address, 'province_code'));
        $bestZone = null;
        $bestSpecificity = -1;

        ShippingZone::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->orderBy('id')
            ->get()
            ->each(function (ShippingZone $zone) use ($country, $region, &$bestZone, &$bestSpecificity): void {
                $countries = collect($zone->countries_json)->map(fn (string $code): string => strtoupper($code));
                $regions = collect($zone->regions_json)->map(fn (string $code): string => strtoupper($code));

                if (! $countries->contains($country)) {
                    return;
                }

                $specificity = $regions->contains($region) ? 2 : 1;

                if ($specificity > $bestSpecificity) {
                    $bestZone = $zone;
                    $bestSpecificity = $specificity;
                }
            });

        return $bestZone;
    }

    public function calculate(ShippingRate $rate, Cart $cart): ?int
    {
        if (! $this->requiresShipping($cart)) {
            return 0;
        }

        return match ($rate->type) {
            ShippingRateType::Flat => (int) data_get($rate->config_json, 'amount', 0),
            ShippingRateType::Weight => $this->weightRate($rate, $cart),
            ShippingRateType::Price => $this->priceRate($rate, $cart),
            ShippingRateType::Carrier => (int) data_get($rate->config_json, 'amount', 1299),
        };
    }

    public function requiresShipping(Cart $cart): bool
    {
        return $this->physicalLines($cart)->isNotEmpty();
    }

    private function weightRate(ShippingRate $rate, Cart $cart): ?int
    {
        $totalWeight = $this->physicalLines($cart)
            ->sum(function (CartLine $line): int {
                $variant = $this->variant($line);

                return ($variant?->weight_g ?? 0) * $line->quantity;
            });

        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
            $minimum = (int) data_get($range, 'min_g', 0);
            $maximum = data_get($range, 'max_g');

            if ($totalWeight >= $minimum && ($maximum === null || $totalWeight <= (int) $maximum)) {
                return (int) data_get($range, 'amount', 0);
            }
        }

        return null;
    }

    private function priceRate(ShippingRate $rate, Cart $cart): ?int
    {
        $subtotal = CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->sum('line_subtotal_amount');

        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
            $minimum = (int) data_get($range, 'min_amount', 0);
            $maximum = data_get($range, 'max_amount');

            if ($subtotal >= $minimum && ($maximum === null || $subtotal <= (int) $maximum)) {
                return (int) data_get($range, 'amount', 0);
            }
        }

        return null;
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function physicalLines(Cart $cart): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->get()
            ->filter(function (CartLine $line): bool {
                return (bool) $this->variant($line)?->requires_shipping;
            });
    }

    private function variant(CartLine $line): ?ProductVariant
    {
        return ProductVariant::withoutGlobalScopes()
            ->whereKey($line->variant_id)
            ->first();
    }
}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Orders/OrderServiceTest.php
@@
 function orderCompletionShippingRate(Store $store): ShippingRate
 {
-    $zone = ShippingZone::withoutGlobalScopes()->create([
-        'store_id' => $store->getKey(),
-        'name' => 'Germany',
-        'countries_json' => ['DE'],
-        'regions_json' => [],
-    ]);
+    $zone = ShippingZone::withoutGlobalScopes()
+        ->where('store_id', $store->getKey())
+        ->where('name', 'Germany')
+        ->first();
+
+    if (! $zone instanceof ShippingZone) {
+        $zone = ShippingZone::withoutGlobalScopes()->create([
+            'store_id' => $store->getKey(),
+            'name' => 'Germany',
+            'countries_json' => ['DE'],
+            'regions_json' => [],
+        ]);
+    }
+
+    $rate = ShippingRate::withoutGlobalScopes()
+        ->where('zone_id', $zone->getKey())
+        ->where('name', 'Standard')
+        ->first();
+
+    if ($rate instanceof ShippingRate) {
+        return $rate;
+    }

     return ShippingRate::withoutGlobalScopes()->create([
         'zone_id' => $zone->getKey(),
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/tests/Feature/Orders/OrderServiceTest.php

unified_diff:
@@ -75,8 +75,24 @@
 {
-    $zone = ShippingZone::withoutGlobalScopes()->create([
-        'store_id' => $store->getKey(),
-        'name' => 'Germany',
-        'countries_json' => ['DE'],
-        'regions_json' => [],
-    ]);
+    $zone = ShippingZone::withoutGlobalScopes()
+        ->where('store_id', $store->getKey())
+        ->where('name', 'Germany')
+        ->first();
+
+    if (! $zone instanceof ShippingZone) {
+        $zone = ShippingZone::withoutGlobalScopes()->create([
+            'store_id' => $store->getKey(),
+            'name' => 'Germany',
+            'countries_json' => ['DE'],
+            'regions_json' => [],
+        ]);
+    }
+
+    $rate = ShippingRate::withoutGlobalScopes()
+        ->where('zone_id', $zone->getKey())
+        ->where('name', 'Standard')
+        ->first();
+
+    if ($rate instanceof ShippingRate) {
+        return $rate;
+    }


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/tests/Feature/Orders/OrderServiceTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


output:
Chunk ID: ff880a
Wall time: 0.6869 seconds
Process exited with code 0
Original token count: 19
Output:

  ...........

  Tests:    11 passed (64 assertions)
  Duration: 0.48s


aggregated_output:
  ...........

  Tests:    11 passed (64 assertions)
  Duration: 0.48s


message: The focused payment/order suite is green. I’m widening to the affected cart and checkout suites next, because the new completion path shares reservation and checkout state transitions with those existing tests.


text: The focused payment/order suite is green. I’m widening to the affected cart and checkout suites next, because the new completion path shares reservation and checkout state transitions with those existing tests.


cmd: php artisan test --compact tests/Feature/Cart tests/Feature/Checkout tests/Feature/Payments tests/Feature/Orders


output:
Chunk ID: aa1bf5
Wall time: 0.7883 seconds
Process exited with code 0
Original token count: 22
Output:

  ......................

  Tests:    22 passed (119 assertions)
  Duration: 0.67s


command: php artisan test --compact tests/Feature/Cart tests/Feature/Checkout tests/Feature/Payments tests/Feature/Orders


message: The shared cart/checkout tests pass as well. I’m going to run the full suite now, then fresh-migrate with seed data to catch migration and seeder regressions from the new tables.


text: The shared cart/checkout tests pass as well. I’m going to run the full suite now, then fresh-migrate with seed data to catch migration and seeder regressions from the new tables.


output:
Chunk ID: 184e76
Wall time: 10.8703 seconds
Process exited with code 0
Original token count: 44
Output:

  ............................................................................
  ...............................

  Tests:    107 passed (489 assertions)
  Duration: 10.76s


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

cmd: php artisan test --compact


cmd: php artisan migrate:fresh --seed --no-interaction


output:
Chunk ID: 9d5e12
Wall time: 0.9656 seconds
Process exited with code 0
Original token count: 1629
Output:

  Dropping all tables ............................................ 5.27ms DONE

   INFO  Preparing database.

  Creating migration table ....................................... 1.79ms DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ........................... 1.46ms DONE
  0001_01_01_000001_create_cache_table ........................... 0.39ms DONE
  0001_01_01_000002_create_jobs_table ............................ 0.87ms DONE
  2025_08_14_170933_add_two_factor_columns_to_users_table ........ 0.46ms DONE
  2026_05_03_211409_create_organizations_table ................... 0.22ms DONE
  2026_05_03_211413_create_stores_table .......................... 4.51ms DONE
  2026_05_03_211416_create_store_domains_table ................... 0.43ms DONE
  2026_05_03_211419_create_store_settings_table .................. 0.24ms DONE
  2026_05_03_211425_create_store_users_table ..................... 0.37ms DONE
  2026_05_03_211556_create_customers_table ....................... 0.35ms DONE
  2026_05_03_212158_create_customer_password_reset_tokens_table .. 0.33ms DONE
  2026_05_03_213856_create_products_table ........................ 0.79ms DONE
  2026_05_03_213857_create_product_options_table ................. 0.37ms DONE
  2026_05_03_213858_create_product_option_values_table ........... 0.33ms DONE
  2026_05_03_213859_create_product_variants_table ................ 0.60ms DONE
  2026_05_03_213900_create_variant_option_values_table ........... 0.25ms DONE
  2026_05_03_213901_create_inventory_items_table ................. 0.32ms DONE
  2026_05_03_213902_create_collections_table ..................... 0.43ms DONE
  2026_05_03_213903_create_collection_products_table ............. 0.36ms DONE
  2026_05_03_213904_create_product_media_table ................... 0.64ms DONE
  2026_05_03_223824_create_themes_table .......................... 0.37ms DONE
  2026_05_03_223830_create_theme_files_table ..................... 0.44ms DONE
  2026_05_03_223837_create_theme_settings_table .................. 0.21ms DONE
  2026_05_03_223841_create_pages_table ........................... 0.42ms DONE
  2026_05_03_223844_create_navigation_menus_table ................ 0.39ms DONE
  2026_05_03_223848_create_navigation_items_table ................ 0.36ms DONE
  2026_05_03_225400_create_carts_table ........................... 0.42ms DONE
  2026_05_03_225405_create_cart_lines_table ...................... 0.51ms DONE
  2026_05_03_225409_create_checkouts_table ....................... 0.71ms DONE
  2026_05_03_225413_create_shipping_zones_table .................. 0.29ms DONE
  2026_05_03_225418_create_shipping_rates_table .................. 0.34ms DONE
  2026_05_03_225422_create_tax_settings_table .................... 0.20ms DONE
  2026_05_03_225427_create_discounts_table ....................... 0.52ms DONE
  2026_05_04_000408_create_customer_addresses_table .............. 0.31ms DONE
  2026_05_04_000408_create_orders_table .......................... 0.87ms DONE
  2026_05_04_000410_create_order_lines_table ..................... 0.49ms DONE
  2026_05_04_000411_create_payments_table ........................ 0.50ms DONE
  2026_05_04_000412_create_refunds_table ......................... 0.41ms DONE
  2026_05_04_000413_create_fulfillments_table .................... 0.41ms DONE
  2026_05_04_000414_create_fulfillment_lines_table ............... 0.39ms DONE

   INFO  Seeding database.

  Database\Seeders\OrganizationSeeder ................................ RUNNING
  Database\Seeders\OrganizationSeeder .............................. 5 ms DONE

  Database\Seeders\StoreSeeder ....................................... RUNNING
  Database\Seeders\StoreSeeder ..................................... 1 ms DONE

  Database\Seeders\StoreDomainSeeder ................................. RUNNING
  Database\Seeders\StoreDomainSeeder ............................... 1 ms DONE

  Database\Seeders\UserSeeder ........................................ RUNNING
  Database\Seeders\UserSeeder .................................... 265 ms DONE

  Database\Seeders\StoreUserSeeder ................................... RUNNING
  Database\Seeders\StoreUserSeeder ................................. 1 ms DONE

  Database\Seeders\StoreSettingsSeeder ............................... RUNNING
  Database\Seeders\StoreSettingsSeeder ............................. 1 ms DONE

  Database\Seeders\CollectionSeeder .................................. RUNNING
  Database\Seeders\CollectionSeeder ................................ 3 ms DONE

  Database\Seeders\ProductSeeder ..................................... RUNNING
  Database\Seeders\ProductSeeder ................................. 197 ms DONE

  Database\Seeders\ThemeSeeder ....................................... RUNNING
  Database\Seeders\ThemeSeeder ..................................... 1 ms DONE

  Database\Seeders\ThemeFileSeeder ................................... RUNNING
  Database\Seeders\ThemeFileSeeder ................................. 1 ms DONE

  Database\Seeders\ThemeSettingsSeeder ............................... RUNNING
  Database\Seeders\ThemeSettingsSeeder ............................. 1 ms DONE

  Database\Seeders\PageSeeder ........................................ RUNNING
  Database\Seeders\PageSeeder ...................................... 2 ms DONE

  Database\Seeders\NavigationMenuSeeder .............................. RUNNING
  Database\Seeders\NavigationMenuSeeder ............................ 1 ms DONE

  Database\Seeders\NavigationItemSeeder .............................. RUNNING
  Database\Seeders\NavigationItemSeeder ............................ 4 ms DONE

  Database\Seeders\TaxSettingsSeeder ................................. RUNNING
  Database\Seeders\TaxSettingsSeeder ............................... 1 ms DONE

  Database\Seeders\ShippingZoneSeeder ................................ RUNNING
  Database\Seeders\ShippingZoneSeeder .............................. 1 ms DONE

  Database\Seeders\ShippingRateSeeder ................................ RUNNING
  Database\Seeders\ShippingRateSeeder .............................. 2 ms DONE

  Database\Seeders\DiscountSeeder .................................... RUNNING
  Database\Seeders\DiscountSeeder .................................. 3 ms DONE

  Database\Seeders\CustomerSeeder .................................... RUNNING
  Database\Seeders\CustomerSeeder ................................ 261 ms DONE


command: php artisan migrate:fresh --seed --no-interaction


command:
/bin/zsh
-lc
git status --short

cmd: git status --short

aggregated_output:
 M app/Models/Customer.php
 M app/Models/Store.php
 M app/Providers/AppServiceProvider.php
 M app/Services/CheckoutService.php
?? app/Contracts/PaymentProvider.php
?? app/Enums/FinancialStatus.php
?? app/Enums/FulfillmentShipmentStatus.php
?? app/Enums/FulfillmentStatus.php
?? app/Enums/OrderStatus.php
?? app/Enums/PaymentMethod.php
?? app/Enums/PaymentStatus.php
?? app/Enums/RefundStatus.php
?? app/Events/OrderCreated.php
?? app/Events/OrderPaid.php
?? app/Exceptions/InvalidOrderOperationException.php
?? app/Exceptions/PaymentFailedException.php
?? app/Models/CustomerAddress.php
?? app/Models/Fulfillment.php
?? app/Models/FulfillmentLine.php
?? app/Models/Order.php
?? app/Models/OrderLine.php
?? app/Models/Payment.php
?? app/Models/Refund.php
?? app/Services/OrderService.php
?? app/Services/PaymentService.php
?? app/Services/Payments/
?? app/ValueObjects/PaymentResult.php
?? app/ValueObjects/RefundResult.php
?? database/factories/CustomerAddressFactory.php
?? database/factories/FulfillmentFactory.php
?? database/factories/FulfillmentLineFactory.php
?? database/factories/OrderFactory.php
?? database/factories/OrderLineFactory.php
?? database/factories/PaymentFactory.php
?? database/factories/RefundFactory.php
?? database/migrations/2026_05_04_000408_create_customer_addresses_table.php
?? database/migrations/2026_05_04_000408_create_orders_table.php
?? database/migrations/2026_05_04_000410_create_order_lines_table.php
?? database/migrations/2026_05_04_000411_create_payments_table.php
?? database/migrations/2026_05_04_000412_create_refunds_table.php
?? database/migrations/2026_05_04_000413_create_fulfillments_table.php
?? database/migrations/2026_05_04_000414_create_fulfillment_lines_table.php
?? database/seeders/CustomerAddressSeeder.php
?? database/seeders/FulfillmentLineSeeder.php
?? database/seeders/FulfillmentSeeder.php
?? database/seeders/OrderLineSeeder.php
?? database/seeders/OrderSeeder.php
?? database/seeders/PaymentSeeder.php
?? database/seeders/RefundSeeder.php
?? tests/Feature/Orders/
?? tests/Feature/Payments/


output:
Chunk ID: 9c05e6
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 515
Output:
 M app/Models/Customer.php
 M app/Models/Store.php
 M app/Providers/AppServiceProvider.php
 M app/Services/CheckoutService.php
?? app/Contracts/PaymentProvider.php
?? app/Enums/FinancialStatus.php
?? app/Enums/FulfillmentShipmentStatus.php
?? app/Enums/FulfillmentStatus.php
?? app/Enums/OrderStatus.php
?? app/Enums/PaymentMethod.php
?? app/Enums/PaymentStatus.php
?? app/Enums/RefundStatus.php
?? app/Events/OrderCreated.php
?? app/Events/OrderPaid.php
?? app/Exceptions/InvalidOrderOperationException.php
?? app/Exceptions/PaymentFailedException.php
?? app/Models/CustomerAddress.php
?? app/Models/Fulfillment.php
?? app/Models/FulfillmentLine.php
?? app/Models/Order.php
?? app/Models/OrderLine.php
?? app/Models/Payment.php
?? app/Models/Refund.php
?? app/Services/OrderService.php
?? app/Services/PaymentService.php
?? app/Services/Payments/
?? app/ValueObjects/PaymentResult.php
?? app/ValueObjects/RefundResult.php
?? database/factories/CustomerAddressFactory.php
?? database/factories/FulfillmentFactory.php
?? database/factories/FulfillmentLineFactory.php
?? database/factories/OrderFactory.php
?? database/factories/OrderLineFactory.php
?? database/factories/PaymentFactory.php
?? database/factories/RefundFactory.php
?? database/migrations/2026_05_04_000408_create_customer_addresses_table.php
?? database/migrations/2026_05_04_000408_create_orders_table.php
?? database/migrations/2026_05_04_000410_create_order_lines_table.php
?? database/migrations/2026_05_04_000411_create_payments_table.php
?? database/migrations/2026_05_04_000412_create_refunds_table.php
?? database/migrations/2026_05_04_000413_create_fulfillments_table.php
?? database/migrations/2026_05_04_000414_create_fulfillment_lines_table.php
?? database/seeders/CustomerAddressSeeder.php
?? database/seeders/FulfillmentLineSeeder.php
?? database/seeders/FulfillmentSeeder.php
?? database/seeders/OrderLineSeeder.php
?? database/seeders/OrderSeeder.php
?? database/seeders/PaymentSeeder.php
?? database/seeders/RefundSeeder.php
?? tests/Feature/Orders/
?? tests/Feature/Payments/


cmd: sed -n '1,80p' database/seeders/OrderSeeder.php


cmd: sed -n '1,80p' database/seeders/PaymentSeeder.php


cmd: sed -n '1,80p' database/seeders/FulfillmentSeeder.php


cmd: sed -n '1,80p' database/seeders/CustomerAddressSeeder.php


command: sed -n '1,80p' database/seeders/PaymentSeeder.php

aggregated_output:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class PaymentSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}


command: sed -n '1,80p' database/seeders/OrderSeeder.php

aggregated_output:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class OrderSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}


command: sed -n '1,80p' database/seeders/CustomerAddressSeeder.php

aggregated_output:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class CustomerAddressSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}


command: sed -n '1,80p' database/seeders/FulfillmentSeeder.php

aggregated_output:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class FulfillmentSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}


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

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class OrderSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}


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

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class PaymentSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}


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

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class FulfillmentSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}


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

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class CustomerAddressSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}


message: The full suite and a fresh seeded migration both pass. I’m updating the progress tracker now to record the Phase 5 backend foundation and the remaining gaps, then I’ll do a final format/test pass before committing this slice.


text: The full suite and a fresh seeded migration both pass. I’m updating the progress tracker now to record the Phase 5 backend foundation and the remaining gaps, then I’ll do a final format/test pass before committing this slice.


command:
/bin/zsh
-lc
sed -n '1,220p' specs/progress.md

progress.md

cmd: sed -n '1,220p' specs/progress.md

aggregated_output:
# Shop Build Progress

## Objective

Build a complete, self-contained Laravel shop system from `specs/*`, with implemented acceptance criteria mapped to evidence, passing automated checks, verified browser flows, independent QA, and meaningful commits.

## Current Status

- Status: in progress
- Active slice: Phase 5 - payments, orders, fulfillment, and customer order views
- Started from: Laravel Livewire/Fortify starter kit with no shop domain tables or models present
- Last updated: 2026-05-04

## Phased Plan

1. Foundation: SQLite config, tenancy tables/models/factories/seeders, store resolution middleware, store scoping, admin/customer auth foundations, policies, and Phase 1 tests.
2. Catalog: product, variant, inventory, collection, media schema/models/services, admin catalog CRUD, storefront browsing, and tests.
3. Storefront theme/layout: theme/page/navigation data, storefront layout, product/collection/search/page views, and smoke browser verification.
4. Cart/checkout/pricing: cart API/UI, discount, shipping, tax, checkout state machine, and tests.
5. Payments/orders/fulfillment: mock PSP, order creation, refunds, fulfillments, customer account order views, and tests.
6. Admin panel: dashboard and all admin list/form/detail pages from Spec 03, with Livewire/Flux interactions.
7. Search/analytics/apps/webhooks: SQLite search, event aggregation, developer token/webhook UI, delivery jobs, and tests.
8. Final verification: full Pest suite, Pint, frontend build, Playwright MCP customer/admin flows, independent QA, fixes, and completion audit.

## Acceptance Checklist

| Area | Criteria Source | Status | Evidence |
| --- | --- | --- | --- |
| Database schema | `specs/01-DATABASE-SCHEMA.md` | partial | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables, and Phase 4 cart/checkout/pricing tables are implemented: carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts. Order/payment/search/analytics/app tables are still missing. |
| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/search`, `/pages/{handle}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`. Storefront REST API now includes cart line CRUD and checkout address/shipping/discount/payment-method selection under `/api/storefront/v1`. Storefront search/suggest, analytics, order lookup/payment processing, and admin REST APIs are still missing. |
| Admin UI | `specs/03-ADMIN-UI.md` | partial | Flux/Livewire admin catalog shell now includes product index/form, collection index/form, and inventory list with auth protection, filtering, pagination, status changes, SKU uniqueness checks, and collection assignment. Order/customer/content/settings admin pages are still missing. |
| Storefront UI | `specs/04-STOREFRONT-UI.md` | partial | Storefront layout, home, collections index/detail, product detail, search, breadcrumbs, price display, product cards, DB-backed pages, seeded navigation menus, cached theme settings, product add-to-cart persistence, cart drawer, cart page with discount and shipping estimates, and checkout address/shipping/discount/payment-selection UI render seeded data. Order confirmation and customer order views are still missing. |
| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `CartService`, `DiscountService`, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through payment selection, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, and product add-to-cart mutations are implemented. Order/payment services are still missing. |
| Auth/security | `specs/06-AUTH-AND-SECURITY.md` | partial | Admin Livewire login/logout, customer guard/provider, customer login/register, login rate limiting, session hardening, and resource policies implemented. Customer password reset and Sanctum token auth still missing. |
| Seed/test data | `specs/07-SEEDERS-AND-TEST-DATA.md` | partial | Seeders create Acme Fashion and Acme Electronics stores/domains/settings/admin access, 6 collections, 25 products, 127 variants, 127 inventory rows, 206 variant option pivots, 2 themes, 6 theme files, 2 theme settings rows, 6 pages, 4 navigation menus, 17 navigation items, 2 tax settings rows, 2 shipping zones, 6 shipping rates, 6 discounts, and no product media/runtime carts. Order/payment seed data is still missing. |
…11741 chars truncated…ibility.
- Seed both `shop.test` and `acme-fashion.test` as storefront domains for the first store so the goal URL and E2E spec URL can both resolve.
- Customer Livewire auth components persist `storeId` from the initial storefront request because Livewire update requests do not run through the original storefront route middleware.
- The seed specification contains an arithmetic conflict: Acme Fashion's per-product variant table sums to 117 variants, while the prose says 107. The implementation follows the concrete per-product table, resulting in 117 Fashion variants and 10 Electronics variants, 127 total.
- Child catalog models without a `store_id` column are tenant-scoped through their parent product relationship. Inventory keeps its denormalized `store_id` and enforces that it matches the variant product store at save time.
- Storefront content pages now resolve from the `pages` table and only published pages are rendered.
- The catalog admin form intentionally blocks active products without a priced variant and duplicate in-store SKUs during UI edits, mirroring service-layer invariants where the current CRUD surface touches product variants directly.
- Navigation tree support is flat for now because the Phase 3 schema defines `navigation_items.position` but no `parent_id`; `NavigationService::buildTree()` returns a flat tree-compatible array with empty children.
- Cart, checkout, and pricing runtime records are not seeded; only deterministic tax, shipping, and discount configuration is seeded. Runtime carts/checkouts are created by services and tests.
- Checkout completion intentionally stops before order creation in this slice. `CheckoutService::completeCheckout()` is a Phase 5 boundary until order, payment, and fulfillment tables/services exist.
- The cart drawer reads the current session/customer cart without creating an empty cart on every storefront render; carts are created on first add-to-cart or checkout/cart service mutation.
- The checkout UI currently reserves inventory by selecting a payment method and leaves payment capture/order creation for Phase 5.
- The cart REST API exposes `cart_version` as the public optimistic concurrency field while the service layer keeps its `expectedVersion` argument; `expected_version` remains accepted as a compatibility alias in API requests.
- Cart page discount codes are validated with `DiscountService`, saved in session, and applied to the checkout when the customer proceeds.

## Open Issues

- Composer does not currently include Sanctum, while Spec 06 requires Sanctum personal access tokens. This will need either an approved dependency addition or a documented compatible alternative before final completion.
- The Herd app URL in the goal is `http://shop.test/`, while the E2E spec also references `http://acme-fashion.test`; domain handling must support seeded store domains and Herd verification.
- Phase 1 still lacks admin/customer password reset at the spec paths and a custom customer password reset token repository that scopes by store_id.
- Policy classes for later resources use generic object parameters until the concrete catalog/order/content models exist.
- `php artisan route:list --except-vendor` hides Livewire full-page routes because their controller is vendor-provided; path-filtered route-list commands are used as evidence for those routes.
- Phase 2 media upload/admin UI is still missing, even though the media schema/job layer exists.
- Product options can be displayed and edited as text in the admin form, but full option matrix generation and option-value reassignment UI are not complete.
- Theme/page/navigation admin management UI is still missing even though the Phase 3 data layer, seeders, services, and storefront consumption exist.
- Storefront search/suggest, analytics event, order lookup, checkout payment processing, and admin REST API endpoints are still missing.
- Checkout order creation, payment capture, discount usage-count increment on successful order completion, and fulfillment are still pending Phase 5.
- Automated browser tests from Spec 08 are still missing; current browser coverage is manual Playwright MCP smoke verification.
- SQLite enum/check constraints from the schema spec are not yet explicitly enforced as database `CHECK` constraints; enum validation is currently enforced through casts/services/model invariants.
- Product media processing now validates and resizes image uploads with GD and cleans predictable generated paths, but browser/admin upload UI is still pending.

## Completion Summary

Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through payment selection, cart-page estimates, and cart/checkout REST APIs are implemented, with known auth/token, media UI, theme admin UI, order/payment, remaining API, browser-test, and database CHECK constraint gaps tracked above.