command: sed -n '1,260p' tests/Feature/CommerceFlowTest.php && sed -n '1,260p' tests/Feature/SearchAnalyticsWebhookTest.php

CommerceFlowTest.php

type: read

SearchAnalyticsWebhookTest.php

stdout:
<?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('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);
});
<?php

use App\Jobs\AggregateAnalytics;
use App\Models\AnalyticsDaily;
use App\Models\AnalyticsEvent;
use App\Models\Product;
use App\Models\SearchQuery;
use App\Models\Store;
use App\Models\WebhookDelivery;
use App\Models\WebhookSubscription;
use App\Services\AnalyticsService;
use App\Services\SearchService;
use App\Services\WebhookService;
use Carbon\CarbonImmutable;
use Database\Seeders\ShopSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;

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('search is tenant scoped, paginated, and logs the query', function (): void {
    $search = new SearchService;
    $results = $search->search($this->store, 'classic', [], 12);

    expect($results->total())->toBe(1)
        ->and($results->first()->title)->toBe('Classic Cotton T-Shirt')
        ->and(SearchQuery::query()->where('query', 'classic')->count())->toBe(1);
});

test('product changes are synchronized to the FTS index and autocomplete', function (): void {
    $product = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail();
    $product->update(['title' => 'Classic Cotton Tee']);

    expect(DB::table('products_fts')->where('product_id', $product->getKey())->value('title'))->toBe('Classic Cotton Tee')
        ->and((new SearchService)->autocomplete($this->store, 'Classic')->first()->title)->toBe('Classic Cotton Tee');
});

test('analytics events aggregate idempotently into daily metrics', function (): void {
    $date = CarbonImmutable::yesterday();
    $analytics = new AnalyticsService;

    foreach (['page_view', 'page_view', 'add_to_cart', 'checkout_started'] as $type) {
        $event = $analytics->track($this->store, $type, ['source' => 'test'], 'session-1');
        $event->forceFill(['created_at' => $date])->save();
    }

    AggregateAnalytics::dispatchSync($this->store, $date->toDateString());
    AggregateAnalytics::dispatchSync($this->store, $date->toDateString());

    $daily = AnalyticsDaily::query()->whereDate('date', $date)->firstOrFail();

    expect($daily->visits_count)->toBe(2)
        ->and($daily->add_to_cart_count)->toBe(1)
        ->and($daily->checkout_started_count)->toBe(1)
        ->and(AnalyticsEvent::query()->where('store_id', $this->store->getKey())->count())->toBe(4);
});

test('webhooks are signed and delivered with platform headers', function (): void {
    Http::fake(['https://hooks.test/*' => Http::response(['ok' => true], 200)]);
    $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'secret_encrypted' => 'test-secret', 'status' => 'active']);

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

    Http::assertSent(function ($request): bool {
        return $request->hasHeader('X-Platform-Signature')
            && $request->header('X-Platform-Event')[0] === 'order.created'
            && $request->header('X-Platform-Delivery-Id') !== null;
    });

    expect(WebhookDelivery::query()->where('webhook_subscription_id', $subscription->getKey())->firstOrFail()->status)->toBe('delivered')
        ->and((new WebhookService)->verify('{"order_id":1001}', (new WebhookService)->sign('{"order_id":1001}', 'test-secret'), 'test-secret'))->toBeTrue();
});


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?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('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);
});
<?php

use App\Jobs\AggregateAnalytics;
use App\Models\AnalyticsDaily;
use App\Models\AnalyticsEvent;
use App\Models\Product;
use App\Models\SearchQuery;
use App\Models\Store;
use App\Models\WebhookDelivery;
use App\Models\WebhookSubscription;
use App\Services\AnalyticsService;
use App\Services\SearchService;
use App\Services\WebhookService;
use Carbon\CarbonImmutable;
use Database\Seeders\ShopSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;

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('search is tenant scoped, paginated, and logs the query', function (): void {
    $search = new SearchService;
    $results = $search->search($this->store, 'classic', [], 12);

    expect($results->total())->toBe(1)
        ->and($results->first()->title)->toBe('Classic Cotton T-Shirt')
        ->and(SearchQuery::query()->where('query', 'classic')->count())->toBe(1);
});

test('product changes are synchronized to the FTS index and autocomplete', function (): void {
    $product = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail();
    $product->update(['title' => 'Classic Cotton Tee']);

    expect(DB::table('products_fts')->where('product_id', $product->getKey())->value('title'))->toBe('Classic Cotton Tee')
        ->and((new SearchService)->autocomplete($this->store, 'Classic')->first()->title)->toBe('Classic Cotton Tee');
});

test('analytics events aggregate idempotently into daily metrics', function (): void {
    $date = CarbonImmutable::yesterday();
    $analytics = new AnalyticsService;

    foreach (['page_view', 'page_view', 'add_to_cart', 'checkout_started'] as $type) {
        $event = $analytics->track($this->store, $type, ['source' => 'test'], 'session-1');
        $event->forceFill(['created_at' => $date])->save();
    }

    AggregateAnalytics::dispatchSync($this->store, $date->toDateString());
    AggregateAnalytics::dispatchSync($this->store, $date->toDateString());

    $daily = AnalyticsDaily::query()->whereDate('date', $date)->firstOrFail();

    expect($daily->visits_count)->toBe(2)
        ->and($daily->add_to_cart_count)->toBe(1)
        ->and($daily->checkout_started_count)->toBe(1)
        ->and(AnalyticsEvent::query()->where('store_id', $this->store->getKey())->count())->toBe(4);
});

test('webhooks are signed and delivered with platform headers', function (): void {
    Http::fake(['https://hooks.test/*' => Http::response(['ok' => true], 200)]);
    $subscription = WebhookSubscription::create(['event' => 'order.created', 'target_url' => 'https://hooks.test/orders', 'secret_encrypted' => 'test-secret', 'status' => 'active']);

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

    Http::assertSent(function ($request): bool {
        return $request->hasHeader('X-Platform-Signature')
            && $request->header('X-Platform-Event')[0] === 'order.created'
            && $request->header('X-Platform-Delivery-Id') !== null;
    });

    expect(WebhookDelivery::query()->where('webhook_subscription_id', $subscription->getKey())->firstOrFail()->status)->toBe('delivered')
        ->and((new WebhookService)->verify('{"order_id":1001}', (new WebhookService)->sign('{"order_id":1001}', 'test-secret'), 'test-secret'))->toBeTrue();
});


exec

status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/tests/Feature/CommerceFlowTest.php\n@@\n test('stale cart versions return a conflict response', function (): void {\n@@\n     $this->postJson(\"http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines\", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertConflict();\n });\n+\n+test('cart and checkout APIs return domain errors as unprocessable responses', function (): void {\n+    $soldOutVariant = Product::query()->where('handle', 'sold-out-limited-tee')->firstOrFail()->variants()->firstOrFail();\n+    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();\n+\n+    $this->postJson(\"http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines\", [\n+        'variant_id' => $soldOutVariant->getKey(),\n+        'quantity' => 1,\n+        'cart_version' => 1,\n+    ])->assertUnprocessable()->assertJsonPath('code', 'insufficient_inventory');\n+\n+    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();\n+    $this->postJson(\"http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines\", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertCreated();\n+    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'discount-error@example.test'])->assertCreated()->json();\n+\n+    $this->postJson(\"http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/apply-discount\", ['code' => 'MISSING'])\n+        ->assertUnprocessable()\n+        ->assertJsonPath('code', 'not_found');\n+});\n \n test('bank transfer keeps inventory reserved until admin confirmation', function (): void {\n*** End Patch";
text(await tools.apply_patch(patch));


type: FileChange

type: update

unified_diff:
@@ -92,2 +92,21 @@

+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 {

status: completed

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


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input:
const r = await tools.exec_command({cmd:"php artisan test tests/Feature/CommerceFlowTest.php","workdir":"/Users/wesner/Herd/shop","yield_time_ms":1000,"max_output_tokens":16000});
text(JSON.stringify(r));


type: CommandExecution

command: php artisan test tests/Feature/CommerceFlowTest.php

type: unknown

stdout:
   PASS  Tests\Feature\CommerceFlowTest
  ✓ guest cart and card checkout create a paid order                     0.16s
  ✓ declined payments release the reservation and do not create an orde… 0.04s
  ✓ stale cart versions return a conflict response                       0.03s
  ✓ cart and checkout APIs return domain errors as unprocessable respon… 0.03s
  ✓ bank transfer keeps inventory reserved until admin confirmation      0.04s

  Tests:    5 passed (30 assertions)
  Duration: 0.33s


text:
Script completed
Wall time 0.5 seconds
Output:

text: {"chunk_id":"d66954","wall_time_seconds":0.333227334,"exit_code":0,"original_token_count":130,"output":"\n   PASS  Tests\\Feature\\CommerceFlowTest\n  ✓ guest cart and card checkout create a paid order                     0.16s  \n  ✓ declined payments release the reservation and do not create an orde… 0.04s  \n  ✓ stale cart versions return a conflict response                       0.03s  \n  ✓ cart and checkout APIs return domain errors as unprocessable respon… 0.03s  \n  ✓ bank transfer keeps inventory reserved until admin confirmation      0.04s  \n\n  Tests:    5 passed (30 assertions)\n  Duration: 0.33s\n\n"}


status: completed

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


text:
Script completed
Wall time 1.2 seconds
Output:

text: {"chunk_id":"9ae181","wall_time_seconds":1.001107667,"session_id":41465,"original_token_count":650,"output":"\n   PASS  Tests\\Unit\\ExampleTest\n  ✓ that true is true\n\n   PASS  Tests\\Unit\\Policies\\StorePolicyTest\n  ✓ product permissions are based on the users role in the current stor… 0.11s  \n  ✓ store deletion is restricted to owners                               0.01s  \n\n   PASS  Tests\\Unit\\PricingEngineTest\n  ✓ tax calculations use integer minor-unit arithmetic\n  ✓ discount calculations cap fixed discounts and allocate percentages\n  ✓ free shipping discounts do not reduce merchandise totals\n  ✓ mock payment provider supports the documented magic cards and deferred transfers\n\n   PASS  Tests\\Unit\\Tenancy\\BelongsToStoreTest\n  ✓ tenant models are created and queried for the current store only     0.01s  \n  ✓ tenant queries return no rows when no current store is resolved      0.01s  \n\n   PASS  Tests\\Feature\\Auth\\AuthenticationTest\n  ✓ login screen can be rendered                                         0.01s  \n  ✓ users can authenticate using the login screen                        0.01s  \n  ✓ users can not authenticate with invalid password                     0.01s  \n  ✓ users with two factor enabled are redirected to two factor challeng… 0.01s  \n  ✓ users can logout                                                     0.01s  \n\n   PASS  Tests\\Feature\\Auth\\EmailVerificationTest\n  ✓ email verification screen can be rendered                            0.01s  \n  ✓ email can be verified                                                0.01s  \n  ✓ email is not verified with invalid hash                              0.01s  \n  ✓ already verified user visiting verification link is redirected with… 0.01s  \n\n   PASS  Tests\\Feature\\Auth\\PasswordConfirmationTest\n  ✓ confirm password screen can be rendered                              0.01s  \n\n   PASS  Tests\\Feature\\Auth\\PasswordResetTest\n  ✓ reset password link screen can be rendered                           0.01s  \n  ✓ reset password link can be requested                                 0.22s  \n  ✓ reset password screen can be rendered                                0.24s  \n  ✓ password can be reset with valid token                               0.24s  \n\n   PASS  Tests\\Feature\\Auth\\RegistrationTest\n  ✓ registration screen can be rendered                                  0.02s  \n  ✓ new users can register                                               0.01s  \n\n   PASS  Tests\\Feature\\Auth\\TwoFactorChallengeTest\n  ✓ two factor challenge redirects to login when not authenticated       0.01s  \n  ✓ two factor challenge can be rendered                                 0.01s  \n"}


command:
/bin/zsh
-lc
php artisan test

cmd: php artisan test

stdout:
   PASS  Tests\Unit\Policies\StorePolicyTest
  ✓ product permissions are based on the users role in the current stor… 0.11s
  ✓ store deletion is restricted to owners                               0.01s

   PASS  Tests\Unit\PricingEngineTest
  ✓ tax calculations use integer minor-unit arithmetic
  ✓ discount calculations cap fixed discounts and allocate percentages
  ✓ free shipping discounts do not reduce merchandise totals
  ✓ mock payment provider supports the documented magic cards and deferred transfers

   PASS  Tests\Unit\Tenancy\BelongsToStoreTest
  ✓ tenant models are created and queried for the current store only     0.01s
  ✓ tenant queries return no rows when no current store is resolved      0.01s

   PASS  Tests\Feature\Auth\AuthenticationTest
  ✓ login screen can be rendered                                         0.01s
  ✓ users can authenticate using the login screen                        0.01s
  ✓ users can not authenticate with invalid password                     0.01s
  ✓ users with two factor enabled are redirected to two factor challeng… 0.01s
  ✓ users can logout                                                     0.01s

   PASS  Tests\Feature\Auth\EmailVerificationTest
  ✓ email verification screen can be rendered                            0.01s
  ✓ email can be verified                                                0.01s
  ✓ email is not verified with invalid hash                              0.01s
  ✓ already verified user visiting verification link is redirected with… 0.01s

   PASS  Tests\Feature\Auth\PasswordConfirmationTest
  ✓ confirm password screen can be rendered                              0.01s

   PASS  Tests\Feature\Auth\PasswordResetTest
  ✓ reset password link screen can be rendered                           0.01s
  ✓ reset password link can be requested                                 0.22s
  ✓ reset password screen can be rendered                                0.24s
  ✓ password can be reset with valid token                               0.24s

   PASS  Tests\Feature\Auth\RegistrationTest
  ✓ registration screen can be rendered                                  0.02s
  ✓ new users can register                                               0.01s

   PASS  Tests\Feature\Auth\TwoFactorChallengeTest
  ✓ two factor challenge redirects to login when not authenticated       0.01s
  ✓ two factor challenge can be rendered                                 0.01s

   PASS  Tests\Feature\CommerceFlowTest
  ✓ guest cart and card checkout create a paid order                     0.05s
  ✓ declined payments release the reservation and do not create an orde… 0.04s
  ✓ stale cart versions return a conflict response                       0.03s
  ✓ cart and checkout APIs return domain errors as unprocessable respon… 0.03s
  ✓ bank transfer keeps inventory reserved until admin confirmation      0.04s

   PASS  Tests\Feature\DashboardTest
  ✓ guests are redirected to the login page                              0.01s
  ✓ authenticated users can visit the dashboard                          0.01s

   PASS  Tests\Feature\ExampleTest
  ✓ it returns a successful response                                     0.01s

   PASS  Tests\Feature\SearchAnalyticsWebhookTest
  ✓ search is tenant scoped, paginated, and logs the query               0.03s
  ✓ product changes are synchronized to the FTS index and autocomplete   0.03s
  ✓ analytics events aggregate idempotently into daily metrics           0.03s
  ✓ webhooks are signed and delivered with platform headers              0.03s

   PASS  Tests\Feature\Settings\PasswordUpdateTest
  ✓ password can be updated                                              0.03s
  ✓ correct password must be provided to update password                 0.03s

   PASS  Tests\Feature\Settings\ProfileUpdateTest
  ✓ profile page is displayed                                            0.02s
  ✓ profile information can be updated                                   0.02s
  ✓ email verification status is unchanged when email address is unchan… 0.02s
  ✓ user can delete their account                                        0.01s
  ✓ correct password must be provided to delete account                  0.02s

   PASS  Tests\Feature\Settings\TwoFactorAuthenticationTest
  ✓ two factor settings page can be rendered                             0.02s
  ✓ two factor settings page requires password confirmation when enable… 0.01s
  ✓ two factor settings page returns forbidden response when two factor… 0.01s
  ✓ two factor authentication disabled when confirmation abandoned betw… 0.01s

   PASS  Tests\Feature\Storefront\CustomerAuthenticationTest
  ✓ customers can log in and access the account route                    0.02s

   PASS  Tests\Feature\Tenancy\ResolveStoreTest
  ✓ a storefront request resolves and shares the store by hostname       0.01s
  ✓ a storefront hostname is cached as a store id                        0.01s
  ✓ unknown storefront hosts return not found                            0.01s
  ✓ suspended storefronts return service unavailable                     0.01s
  ✓ admin requests resolve only the session store the user belongs to    0.01s
  ✓ admin requests reject a session store without membership             0.01s

  Tests:    57 passed (152 assertions)
  Duration: 1.57s


status: completed

input:
const r = await tools.write_stdin({session_id:41465,chars:"",yield_time_ms:1000,max_output_tokens:20000});
text(r.output);


text:
Script completed
Wall time 0.0 seconds
Output:

text:
   PASS  Tests\Feature\CommerceFlowTest
  ✓ guest cart and card checkout create a paid order                     0.05s
  ✓ declined payments release the reservation and do not create an orde… 0.04s
  ✓ stale cart versions return a conflict response                       0.03s
  ✓ cart and checkout APIs return domain errors as unprocessable respon… 0.03s
  ✓ bank transfer keeps inventory reserved until admin confirmation      0.04s

   PASS  Tests\Feature\DashboardTest
  ✓ guests are redirected to the login page                              0.01s
  ✓ authenticated users can visit the dashboard                          0.01s

   PASS  Tests\Feature\ExampleTest
  ✓ it returns a successful response                                     0.01s

   PASS  Tests\Feature\SearchAnalyticsWebhookTest
  ✓ search is tenant scoped, paginated, and logs the query               0.03s
  ✓ product changes are synchronized to the FTS index and autocomplete   0.03s
  ✓ analytics events aggregate idempotently into daily metrics           0.03s
  ✓ webhooks are signed and delivered with platform headers              0.03s

   PASS  Tests\Feature\Settings\PasswordUpdateTest
  ✓ password can be updated                                              0.03s
  ✓ correct password must be provided to update password                 0.03s

   PASS  Tests\Feature\Settings\ProfileUpdateTest
  ✓ profile page is displayed                                            0.02s
  ✓ profile information can be updated                                   0.02s
  ✓ email verification status is unchanged when email address is unchan… 0.02s
  ✓ user can delete their account                                        0.01s
  ✓ correct password must be provided to delete account                  0.02s

   PASS  Tests\Feature\Settings\TwoFactorAuthenticationTest
  ✓ two factor settings page can be rendered                             0.02s
  ✓ two factor settings page requires password confirmation when enable… 0.01s
  ✓ two factor settings page returns forbidden response when two factor… 0.01s
  ✓ two factor authentication disabled when confirmation abandoned betw… 0.01s

   PASS  Tests\Feature\Storefront\CustomerAuthenticationTest
  ✓ customers can log in and access the account route                    0.02s

   PASS  Tests\Feature\Tenancy\ResolveStoreTest
  ✓ a storefront request resolves and shares the store by hostname       0.01s
  ✓ a storefront hostname is cached as a store id                        0.01s
  ✓ unknown storefront hosts return not found                            0.01s
  ✓ suspended storefronts return service unavailable                     0.01s
  ✓ admin requests resolve only the session store the user belongs to    0.01s
  ✓ admin requests reject a session store without membership             0.01s

  Tests:    57 passed (152 assertions)
  Duration: 1.57s


status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,260p' specs/progress.md 2>/dev/null || true && git diff --stat && git status --short | tail -40","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":14000});
text(r.output);


command: sed -n '1,260p' specs/progress.md 2>/dev/null || true && git diff --stat && git status --short | tail -40

stdout:
# Implementation Progress

## Foundation tenancy slice

- [x] SQLite, cache, session, and queue defaults configured for the self-contained app.
- [x] Organization, store, domain, store-user, and store-settings schema/model relationships established.
- [x] Store status, domain type, and store-user role enums available.
- [x] `BelongsToStore` and `StoreScope` enforce current-store query and create boundaries.
- [x] `ResolveStore` supports hostname-based storefront resolution and session-based admin resolution.
- [x] Store-role helpers and policy scaffolding added for the specified admin resources.
- [x] Focused Pest coverage added for resolution, tenant isolation, and role authorization.

Catalog, cart, checkout, order, and storefront UI implementation remains outside this bounded slice.
 app/Http/Middleware/ResolveStore.php   |  28 +++++++-
 app/Providers/AppServiceProvider.php   |  26 +++++++-
 boost.json                             |   2 +-
 bootstrap/app.php                      |   5 ++
 database/seeders/DatabaseSeeder.php    |   9 +--
 resources/views/layouts/auth.blade.php |   4 +-
 routes/console.php                     |   6 ++
 routes/web.php                         | 113 +++++++++++++++++++++++++++++++--
 8 files changed, 175 insertions(+), 18 deletions(-)
?? 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


text:
Script completed
Wall time 0.1 seconds
Output:

text:
# Implementation Progress

## Foundation tenancy slice

- [x] SQLite, cache, session, and queue defaults configured for the self-contained app.
- [x] Organization, store, domain, store-user, and store-settings schema/model relationships established.
- [x] Store status, domain type, and store-user role enums available.
- [x] `BelongsToStore` and `StoreScope` enforce current-store query and create boundaries.
- [x] `ResolveStore` supports hostname-based storefront resolution and session-based admin resolution.
- [x] Store-role helpers and policy scaffolding added for the specified admin resources.
- [x] Focused Pest coverage added for resolution, tenant isolation, and role authorization.

Catalog, cart, checkout, order, and storefront UI implementation remains outside this bounded slice.
 app/Http/Middleware/ResolveStore.php   |  28 +++++++-
 app/Providers/AppServiceProvider.php   |  26 +++++++-
 boost.json                             |   2 +-
 bootstrap/app.php                      |   5 ++
 database/seeders/DatabaseSeeder.php    |   9 +--
 resources/views/layouts/auth.blade.php |   4 +-
 routes/console.php                     |   6 ++
 routes/web.php                         | 113 +++++++++++++++++++++++++++++++--
 8 files changed, 175 insertions(+), 18 deletions(-)
?? 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


status: completed

input:
const patch = "*** Begin Patch\n*** Delete File: /Users/wesner/Herd/shop/specs/progress.md\n*** Add File: /Users/wesner/Herd/shop/specs/progress.md\n+# Implementation Progress\n+\n+The core self-contained shop implementation is in place and verified with Pest and Playwright.\n+\n+## Completed\n+\n+- [x] Foundation: SQLite configuration, tenant schema/models, store resolution, global tenant scope, roles, and policies.\n+- [x] Catalog: products, variants, options, inventory, collections, media, product status transitions, and seeded demo data.\n+- [x] Storefront: home, collections, product detail, variant selection, cart, search, static pages, responsive layouts, and theme scaffolding.\n+- [x] Cart and checkout: session/customer carts, optimistic cart versions, discount codes, addresses, shipping rates, tax calculation, and checkout expiry.\n+- [x] Payments and orders: mock card/PayPal/bank-transfer PSP, idempotent payment handling, inventory reservations, order snapshots, confirmation, cancellation, refunds, and fulfillment guards.\n+- [x] Authentication: Fortify admin authentication plus separate tenant-scoped customer authentication, registration, password reset, email verification, and 2FA support.\n+- [x] Admin: dashboard, product/order/customer/discount/settings screens, role middleware, and routed sections for inventory, collections, themes, pages, navigation, apps, developers, analytics, and search settings.\n+- [x] Search, analytics, apps, and webhooks: SQLite FTS5 indexing, query logging, analytics aggregation, signed webhook delivery, retries, and subscription pausing.\n+- [x] Automated coverage: unit and feature tests for pricing, tenancy, authentication, commerce flows, search, analytics, webhooks, and customer sessions.\n+- [x] Browser acceptance: storefront browsing, product add-to-cart, discount application, address/shipping/payment checkout, order confirmation, customer account, admin login, and admin section smoke checks.\n+\n+## Verification\n+\n+- `php artisan test`: 57 passing tests, 152 assertions.\n+- `npm run build`: passing.\n+- `vendor/bin/pint --dirty --format agent`: run after the final PHP changes.\n+- Playwright MCP browser checks: no storefront/admin page errors in the completed smoke paths.\n+\n+## Remaining hardening\n+\n+- Replace routed admin section placeholders with full CRUD screens as those areas are expanded.\n+- Add broader browser coverage for refund/fulfillment actions, customer registration/reset flows, and mobile interaction states.\n*** End Patch";
text(await tools.apply_patch(patch));


type: add

content:
# Implementation Progress

The core self-contained shop implementation is in place and verified with Pest and Playwright.

## Completed

- [x] Foundation: SQLite configuration, tenant schema/models, store resolution, global tenant scope, roles, and policies.
- [x] Catalog: products, variants, options, inventory, collections, media, product status transitions, and seeded demo data.
- [x] Storefront: home, collections, product detail, variant selection, cart, search, static pages, responsive layouts, and theme scaffolding.
- [x] Cart and checkout: session/customer carts, optimistic cart versions, discount codes, addresses, shipping rates, tax calculation, and checkout expiry.
- [x] Payments and orders: mock card/PayPal/bank-transfer PSP, idempotent payment handling, inventory reservations, order snapshots, confirmation, cancellation, refunds, and fulfillment guards.
- [x] Authentication: Fortify admin authentication plus separate tenant-scoped customer authentication, registration, password reset, email verification, and 2FA support.
- [x] Admin: dashboard, product/order/customer/discount/settings screens, role middleware, and routed sections for inventory, collections, themes, pages, navigation, apps, developers, analytics, and search settings.
- [x] Search, analytics, apps, and webhooks: SQLite FTS5 indexing, query logging, analytics aggregation, signed webhook delivery, retries, and subscription pausing.
- [x] Automated coverage: unit and feature tests for pricing, tenancy, authentication, commerce flows, search, analytics, webhooks, and customer sessions.
- [x] Browser acceptance: storefront browsing, product add-to-cart, discount application, address/shipping/payment checkout, order confirmation, customer account, admin login, and admin section smoke checks.

## Verification

- `php artisan test`: 57 passing tests, 152 assertions.
- `npm run build`: passing.
- `vendor/bin/pint --dirty --format agent`: run after the final PHP changes.
- Playwright MCP browser checks: no storefront/admin page errors in the completed smoke paths.

## Remaining hardening

- Replace routed admin section placeholders with full CRUD screens as those areas are expanded.
- Add broader browser coverage for refund/fulfillment actions, customer registration/reset flows, and mobile interaction states.

stdout:
Success. Updated the following files:
A /Users/wesner/Herd/shop/specs/progress.md
D /Users/wesner/Herd/shop/specs/progress.md


text:
Script completed
Wall time 0.0 seconds
Output:


type: AgentMessage

type: Text

text: Automated verification is green at 57 tests / 152 assertions. The browser smoke pass now covers storefront checkout and customer account plus the admin dashboard and routed admin sections; I’m doing the final code-quality pass and integrating the independent audit findings next.

phase: commentary


type: message

role: assistant


status: completed

input:
const r = await tools.exec_command({cmd:"vendor/bin/pint --dirty --format agent","workdir":"/Users/wesner/Herd/shop","yield_time_ms":1000,"max_output_tokens":16000});
text(JSON.stringify(r));


text:
Script completed
Wall time 1.2 seconds
Output:

text: {"chunk_id":"a74838","wall_time_seconds":1.002024834,"session_id":15997,"original_token_count":0,"output":""}


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

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

result: pass


status: completed

input:
const r = await tools.write_stdin({session_id:15997,chars:"",yield_time_ms:1000,max_output_tokens:16000});
text(r.output);


text:
Script completed
Wall time 0.0 seconds
Output:

text: {"result":"pass"}


status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,260p' specs/01-DATABASE-SCHEMA.md && sed -n '1,280p' specs/05-BUSINESS-LOGIC.md","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":32000});
text(r.output);


command: sed -n '1,260p' specs/01-DATABASE-SCHEMA.md && sed -n '1,280p' specs/05-BUSINESS-LOGIC.md

stdout:
# 01 - Database Schema Specification

> E-commerce platform - SQLite database schema for Laravel 12

---

## Conventions

| Convention | Detail |
|---|---|
| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |
| Tenant isolation | `store_id` foreign key on every tenant-scoped table |
| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |
| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |
| JSON data | Text column - Laravel casts to/from JSON at the application layer |
| Enumerations | Check constraints on text columns |
| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |
| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |
| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |

---

## Epic 1 - Foundation and Multi-Store Tenancy

### organizations

Billing entity that owns one or more stores.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| name | TEXT | No | - | - | Organization display name |
| billing_email | TEXT | No | - | - | Primary billing contact |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email

---

### stores

Individual storefront belonging to an organization.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |
| name | TEXT | No | - | - | Store display name |
| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |
| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |
| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| default_locale | TEXT | No | 'en' | - | IETF locale tag |
| timezone | TEXT | No | 'UTC' | - | IANA timezone |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing
- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org
- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status

---

### store_domains

Custom domains attached to a store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| hostname | TEXT | No | - | UNIQUE per table | e.g. "shop.example.com" |
| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |
| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |
| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing
- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store
- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain

---

### users

Admin/staff users who manage stores (not storefront customers).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| email | TEXT | No | - | UNIQUE per table | Login email |
| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |
| name | TEXT | No | - | - | Display name |
| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |
| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |
| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |
| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |
| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup
- `idx_users_status` on (`status`) - Filtering users by active/disabled

**Notes:** The `two_factor_*` columns align with the existing migration `2025_08_14_170933_add_two_factor_columns_to_users_table.php`. The `password_hash` column maps to Laravel's default `password` field internally but stores a bcrypt/argon2 hash.

---

### store_users

Pivot table linking users to stores with a role. Uses composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |
| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |
| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Primary Key:** (`store_id`, `user_id`)

**Indexes:**

- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user
- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store

---

### store_settings

Single-row-per-store JSON settings bag.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| settings_json | TEXT | No | '{}' | - | JSON settings object |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.

---

## Epic 2 - Catalog, Collections, Inventory, and Media

### products

Core product record, scoped to a store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| title | TEXT | No | - | - | Product display title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |
| description_html | TEXT | Yes | NULL | - | Rich-text description |
| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |
| product_type | TEXT | Yes | NULL | - | Category classification |
| tags | TEXT | No | '[]' | - | JSON array, e.g. ["summer","sale"] |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store
- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store
- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date
- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor
- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type

**Notes:** `tags` is a JSON-encoded array (e.g. `["summer","sale"]`). The `handle` is unique per store, not globally.

---

### product_options

Named option dimensions for a product (e.g. Size, Color).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| name | TEXT | No | - | - | Option name, e.g. "Size", "Color" |
| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |

**Indexes:**

- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product
- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product

---

### product_option_values

Individual values within a product option (e.g. "Small", "Medium", "Large").

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |
| value | TEXT | No | - | - | Display value, e.g. "Small" |
| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |

**Indexes:**

- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option
- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option

---

### product_variants

Purchasable SKU-level variant of a product.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| sku | TEXT | Yes | NULL | - | Stock keeping unit |
| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |
| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |
| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| weight_g | INTEGER | Yes | NULL | - | Weight in grams |
| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |
| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |
| position | INTEGER | No | 0 | - | Display ordering |
| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product
- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants
- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup
- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing
- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant

**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).

---

### variant_option_values

Maps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
# 05 - Business Logic Specification

> E-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12

---

## Conventions

| Convention | Detail |
|---|---|
| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |
| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |
| Rounding | Round each line independently, then sum (prevents rounding drift) |
| Database | SQLite - single-writer, WAL mode, no advisory locks needed |
| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |
| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |

---

## 1. Multi-Tenancy and Store Isolation

### 1.1 Tenant Resolution Middleware

**Class:** `App\Http\Middleware\ResolveStore`

**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.

#### Storefront Route Resolution (from request hostname)

1. Extract hostname from the incoming HTTP request.
2. Look up `store_domains` table where `hostname` matches.
3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.
4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.
5. If hostname is not found in `store_domains`: return HTTP 404.
6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.

#### Admin Route Resolution (from session)

1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).
2. Verify the authenticated user has a `store_users` record for that store.
3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.

```mermaid
flowchart TD
    A[Incoming Request] --> B{Route type?}
    B -->|Storefront| C[Extract hostname]
    C --> D{Hostname in cache?}
    D -->|Yes| E[Load store from cache]
    D -->|No| F[Query store_domains table]
    F --> G{Found?}
    G -->|No| H[Return 404]
    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]
    I --> E
    E --> J{Store suspended?}
    J -->|Yes| K[Return 503 maintenance]
    J -->|No| L[Bind store as singleton, continue]

    B -->|Admin| M[Read store_id from session]
    M --> N{User has store_users record?}
    N -->|No| O[Return 403]
    N -->|Yes| P{Store suspended?}
    P -->|Yes| Q[Return 403 for mutations]
    P -->|No| L
```

### 1.2 Store Scoping

**Trait:** `App\Models\Concerns\BelongsToStore`
**Global Scope:** `App\Models\Scopes\StoreScope`

The trait applies a global query scope to every model that uses it. The scope adds a `WHERE store_id = <current_store_id>` clause to all queries automatically.

**Behavior:**

- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).
- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.
- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.

**Models that must use this trait:**
products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.

### 1.3 Role-Based Access Control

**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`

**Permission Matrix:**

| Permission | Owner | Admin | Staff | Support |
|---|---|---|---|---|
| Manage store settings | Yes | Yes | No | No |
| Manage staff | Yes | Yes | No | No |
| Manage products | Yes | Yes | Yes | No |
| Manage orders | Yes | Yes | Yes | Yes (read-only) |
| Manage discounts | Yes | Yes | Yes | No |
| Manage themes | Yes | Yes | No | No |
| Manage apps | Yes | Yes | No | No |
| View analytics | Yes | Yes | Yes | No |
| Manage shipping/tax | Yes | Yes | No | No |
| Process refunds | Yes | Yes | No | No |
| Create fulfillments | Yes | Yes | Yes | No |

**Implementation requirements:**

- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).
- Register gate definitions in a service provider.
- Each policy method receives the User, looks up the user's role for the current store via the `store_users` pivot table, and checks the matrix above.
- Add a helper method on the User model - `roleForStore(store)` - that returns the user's role for a given store or null if the user has no access.

---

## 2. Product Lifecycle

### 2.1 Status Transitions

**Enum:** `App\Enums\ProductStatus` - values: `draft`, `active`, `archived`

```mermaid
stateDiagram-v2
    [*] --> Draft : Product created
    Draft --> Active : Publish (requires variant with price > 0, title not empty)
    Draft --> Archived : Archive
    Active --> Archived : Archive (hide from storefront)
    Archived --> Active : Re-publish (same preconditions as Draft->Active)
    Active --> Draft : Revert (BLOCKED if order_lines exist)
    Archived --> Draft : Revert (BLOCKED if order_lines exist)
```

**Transition rules:**

| From | To | Preconditions |
|---|---|---|
| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |
| Draft | Archived | None |
| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |
| Archived | Active | Same preconditions as Draft to Active |
| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |
| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |

**Deletion rules:**
- Only `draft` products with zero `order_lines` references may be hard-deleted.
- Products with existing order references must be archived, never deleted, to preserve order history integrity.

**Service:** `App\Services\ProductService`
- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.
- On activation: set `published_at` to current time if not already set.
- Dispatch `ProductStatusChanged` event after successful transition.

### 2.2 Variant and Option Matrix

**Rules:**

- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.
- Each option has 1 or more values, stored in `product_option_values`.
- Variants represent the cartesian product of option values (or a manually curated subset).
- When a product has no options, a single "Default" variant is auto-created with `is_default = 1`.

**Matrix rebuild logic (when options change):**

```
PROCEDURE rebuildMatrix(product):
    current_option_values = load all option values grouped by option
    desired_combos = cartesianProduct(current_option_values)
    existing_variants = load all variants with their option value sets

    FOR EACH combo IN desired_combos:
        IF combo matches an existing variant's option value set:
            SKIP (preserve existing variant - prices, SKUs, inventory intact)
        ELSE:
            CREATE new variant with default pricing from first existing variant

    FOR EACH existing_variant NOT matching any desired combo:
        IF variant has order_line references:
            ARCHIVE variant (set status = 'archived')
        ELSE:
            DELETE variant
```

**Service:** `App\Services\VariantMatrixService`

### 2.3 SKU Uniqueness

- Combination of store_id (derived through the product's store) and SKU must be unique.
- Enforced at application level before save.
- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).
- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.

### 2.4 Handle/Slug Generation

**Helper class:** `App\Support\HandleGenerator`

- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).
- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.
- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.
- Merchants can manually edit the handle; on save, re-validate uniqueness.
- Same logic applies to collections and pages.

---

## 3. Inventory Management

### 3.1 Tracking Model

Each `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).

| Field | Description |
|---|---|
| `quantity_on_hand` | Total physical stock count |
| `quantity_reserved` | Units held for active checkouts or pending orders |
| **Available** (computed) | `quantity_on_hand - quantity_reserved` |

### 3.2 Inventory Policy

Per-variant policy stored in `inventory_items.policy`:

| Policy | Behavior |
|---|---|
| `deny` | Block add-to-cart when available <= 0. Storefront displays "Out of stock". |
| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays "Available on backorder". |

### 3.3 Inventory Operations

**Service:** `App\Services\InventoryService`

All operations must execute within a database transaction.

```mermaid
flowchart LR
    subgraph "Stock Lifecycle"
        A[On Hand] -->|Reserve| B[Reserved]
        B -->|Commit on payment| C[Deducted]
        B -->|Release on expiry| A
        C -->|Restock on refund| A
    end
```

**Operation definitions:**

| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |
|---|---|---|---|---|
| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
| **Restock** | Refund processed with restock flag | +quantity | No change | None |

### 3.4 Concurrency

SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.

---

## 4. Cart Service

### 4.1 Cart Lifecycle

**Service:** `App\Services\CartService`

```mermaid
stateDiagram-v2
    [*] --> active : Cart created
    active --> converted : Checkout completed successfully
    active --> abandoned : Cleanup job (14 days inactive)
```

**Cart identification:**
- Guest users: cart ID stored in session cookie.
- Authenticated customers: cart linked via `customer_id` foreign key.

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.

**Cart merge on login:**

```
PROCEDURE mergeGuestCart(guestCart, customerCart):
    FOR EACH line IN guestCart.lines:
        existingLine = customerCart.findLineByVariant(line.variant_id)
        IF existingLine EXISTS:
            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
            RECALCULATE existingLine amounts
        ELSE:
            MOVE line to customerCart


text:
Script completed
Wall time 0.1 seconds
Output:

text:
# 01 - Database Schema Specification

> E-commerce platform - SQLite database schema for Laravel 12

---

## Conventions

| Convention | Detail |
|---|---|
| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |
| Tenant isolation | `store_id` foreign key on every tenant-scoped table |
| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |
| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |
| JSON data | Text column - Laravel casts to/from JSON at the application layer |
| Enumerations | Check constraints on text columns |
| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |
| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |
| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |

---

## Epic 1 - Foundation and Multi-Store Tenancy

### organizations

Billing entity that owns one or more stores.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| name | TEXT | No | - | - | Organization display name |
| billing_email | TEXT | No | - | - | Primary billing contact |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email

---

### stores

Individual storefront belonging to an organization.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |
| name | TEXT | No | - | - | Store display name |
| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |
| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |
| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| default_locale | TEXT | No | 'en' | - | IETF locale tag |
| timezone | TEXT | No | 'UTC' | - | IANA timezone |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing
- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org
- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status

---

### store_domains

Custom domains attached to a store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| hostname | TEXT | No | - | UNIQUE per table | e.g. "shop.example.com" |
| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |
| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |
| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing
- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store
- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain

---

### users

Admin/staff users who manage stores (not storefront customers).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| email | TEXT | No | - | UNIQUE per table | Login email |
| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |
| name | TEXT | No | - | - | Display name |
| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |
| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |
| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |
| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |
| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup
- `idx_users_status` on (`status`) - Filtering users by active/disabled

**Notes:** The `two_factor_*` columns align with the existing migration `2025_08_14_170933_add_two_factor_columns_to_users_table.php`. The `password_hash` column maps to Laravel's default `password` field internally but stores a bcrypt/argon2 hash.

---

### store_users

Pivot table linking users to stores with a role. Uses composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |
| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |
| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Primary Key:** (`store_id`, `user_id`)

**Indexes:**

- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user
- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store

---

### store_settings

Single-row-per-store JSON settings bag.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| settings_json | TEXT | No | '{}' | - | JSON settings object |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.

---

## Epic 2 - Catalog, Collections, Inventory, and Media

### products

Core product record, scoped to a store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| title | TEXT | No | - | - | Product display title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |
| description_html | TEXT | Yes | NULL | - | Rich-text description |
| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |
| product_type | TEXT | Yes | NULL | - | Category classification |
| tags | TEXT | No | '[]' | - | JSON array, e.g. ["summer","sale"] |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store
- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store
- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date
- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor
- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type

**Notes:** `tags` is a JSON-encoded array (e.g. `["summer","sale"]`). The `handle` is unique per store, not globally.

---

### product_options

Named option dimensions for a product (e.g. Size, Color).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| name | TEXT | No | - | - | Option name, e.g. "Size", "Color" |
| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |

**Indexes:**

- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product
- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product

---

### product_option_values

Individual values within a product option (e.g. "Small", "Medium", "Large").

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |
| value | TEXT | No | - | - | Display value, e.g. "Small" |
| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |

**Indexes:**

- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option
- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option

---

### product_variants

Purchasable SKU-level variant of a product.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| sku | TEXT | Yes | NULL | - | Stock keeping unit |
| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |
| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |
| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| weight_g | INTEGER | Yes | NULL | - | Weight in grams |
| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |
| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |
| position | INTEGER | No | 0 | - | Display ordering |
| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product
- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants
- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup
- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing
- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant

**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).

---

### variant_option_values

Maps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
# 05 - Business Logic Specification

> E-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12

---

## Conventions

| Convention | Detail |
|---|---|
| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |
| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |
| Rounding | Round each line independently, then sum (prevents rounding drift) |
| Database | SQLite - single-writer, WAL mode, no advisory locks needed |
| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |
| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |

---

## 1. Multi-Tenancy and Store Isolation

### 1.1 Tenant Resolution Middleware

**Class:** `App\Http\Middleware\ResolveStore`

**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.

#### Storefront Route Resolution (from request hostname)

1. Extract hostname from the incoming HTTP request.
2. Look up `store_domains` table where `hostname` matches.
3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.
4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.
5. If hostname is not found in `store_domains`: return HTTP 404.
6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.

#### Admin Route Resolution (from session)

1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).
2. Verify the authenticated user has a `store_users` record for that store.
3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.

```mermaid
flowchart TD
    A[Incoming Request] --> B{Route type?}
    B -->|Storefront| C[Extract hostname]
    C --> D{Hostname in cache?}
    D -->|Yes| E[Load store from cache]
    D -->|No| F[Query store_domains table]
    F --> G{Found?}
    G -->|No| H[Return 404]
    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]
    I --> E
    E --> J{Store suspended?}
    J -->|Yes| K[Return 503 maintenance]
    J -->|No| L[Bind store as singleton, continue]

    B -->|Admin| M[Read store_id from session]
    M --> N{User has store_users record?}
    N -->|No| O[Return 403]
    N -->|Yes| P{Store suspended?}
    P -->|Yes| Q[Return 403 for mutations]
    P -->|No| L
```

### 1.2 Store Scoping

**Trait:** `App\Models\Concerns\BelongsToStore`
**Global Scope:** `App\Models\Scopes\StoreScope`

The trait applies a global query scope to every model that uses it. The scope adds a `WHERE store_id = <current_store_id>` clause to all queries automatically.

**Behavior:**

- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).
- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.
- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.

**Models that must use this trait:**
products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.

### 1.3 Role-Based Access Control

**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`

**Permission Matrix:**

| Permission | Owner | Admin | Staff | Support |
|---|---|---|---|---|
| Manage store settings | Yes | Yes | No | No |
| Manage staff | Yes | Yes | No | No |
| Manage products | Yes | Yes | Yes | No |
| Manage orders | Yes | Yes | Yes | Yes (read-only) |
| Manage discounts | Yes | Yes | Yes | No |
| Manage themes | Yes | Yes | No | No |
| Manage apps | Yes | Yes | No | No |
| View analytics | Yes | Yes | Yes | No |
| Manage shipping/tax | Yes | Yes | No | No |
| Process refunds | Yes | Yes | No | No |
| Create fulfillments | Yes | Yes | Yes | No |

**Implementation requirements:**

- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).
- Register gate definitions in a service provider.
- Each policy method receives the User, looks up the user's role for the current store via the `store_users` pivot table, and checks the matrix above.
- Add a helper method on the User model - `roleForStore(store)` - that returns the user's role for a given store or null if the user has no access.

---

## 2. Product Lifecycle

### 2.1 Status Transitions

**Enum:** `App\Enums\ProductStatus` - values: `draft`, `active`, `archived`

```mermaid
stateDiagram-v2
    [*] --> Draft : Product created
    Draft --> Active : Publish (requires variant with price > 0, title not empty)
    Draft --> Archived : Archive
    Active --> Archived : Archive (hide from storefront)
    Archived --> Active : Re-publish (same preconditions as Draft->Active)
    Active --> Draft : Revert (BLOCKED if order_lines exist)
    Archived --> Draft : Revert (BLOCKED if order_lines exist)
```

**Transition rules:**

| From | To | Preconditions |
|---|---|---|
| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |
| Draft | Archived | None |
| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |
| Archived | Active | Same preconditions as Draft to Active |
| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |
| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |

**Deletion rules:**
- Only `draft` products with zero `order_lines` references may be hard-deleted.
- Products with existing order references must be archived, never deleted, to preserve order history integrity.

**Service:** `App\Services\ProductService`
- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.
- On activation: set `published_at` to current time if not already set.
- Dispatch `ProductStatusChanged` event after successful transition.

### 2.2 Variant and Option Matrix

**Rules:**

- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.
- Each option has 1 or more values, stored in `product_option_values`.
- Variants represent the cartesian product of option values (or a manually curated subset).
- When a product has no options, a single "Default" variant is auto-created with `is_default = 1`.

**Matrix rebuild logic (when options change):**

```
PROCEDURE rebuildMatrix(product):
    current_option_values = load all option values grouped by option
    desired_combos = cartesianProduct(current_option_values)
    existing_variants = load all variants with their option value sets

    FOR EACH combo IN desired_combos:
        IF combo matches an existing variant's option value set:
            SKIP (preserve existing variant - prices, SKUs, inventory intact)
        ELSE:
            CREATE new variant with default pricing from first existing variant

    FOR EACH existing_variant NOT matching any desired combo:
        IF variant has order_line references:
            ARCHIVE variant (set status = 'archived')
        ELSE:
            DELETE variant
```

**Service:** `App\Services\VariantMatrixService`

### 2.3 SKU Uniqueness

- Combination of store_id (derived through the product's store) and SKU must be unique.
- Enforced at application level before save.
- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).
- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.

### 2.4 Handle/Slug Generation

**Helper class:** `App\Support\HandleGenerator`

- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).
- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.
- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.
- Merchants can manually edit the handle; on save, re-validate uniqueness.
- Same logic applies to collections and pages.

---

## 3. Inventory Management

### 3.1 Tracking Model

Each `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).

| Field | Description |
|---|---|
| `quantity_on_hand` | Total physical stock count |
| `quantity_reserved` | Units held for active checkouts or pending orders |
| **Available** (computed) | `quantity_on_hand - quantity_reserved` |

### 3.2 Inventory Policy

Per-variant policy stored in `inventory_items.policy`:

| Policy | Behavior |
|---|---|
| `deny` | Block add-to-cart when available <= 0. Storefront displays "Out of stock". |
| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays "Available on backorder". |

### 3.3 Inventory Operations

**Service:** `App\Services\InventoryService`

All operations must execute within a database transaction.

```mermaid
flowchart LR
    subgraph "Stock Lifecycle"
        A[On Hand] -->|Reserve| B[Reserved]
        B -->|Commit on payment| C[Deducted]
        B -->|Release on expiry| A
        C -->|Restock on refund| A
    end
```

**Operation definitions:**

| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |
|---|---|---|---|---|
| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
| **Restock** | Refund processed with restock flag | +quantity | No change | None |

### 3.4 Concurrency

SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.

---

## 4. Cart Service

### 4.1 Cart Lifecycle

**Service:** `App\Services\CartService`

```mermaid
stateDiagram-v2
    [*] --> active : Cart created
    active --> converted : Checkout completed successfully
    active --> abandoned : Cleanup job (14 days inactive)
```

**Cart identification:**
- Guest users: cart ID stored in session cookie.
- Authenticated customers: cart linked via `customer_id` foreign key.

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.

**Cart merge on login:**

```
PROCEDURE mergeGuestCart(guestCart, customerCart):
    FOR EACH line IN guestCart.lines:
        existingLine = customerCart.findLineByVariant(line.variant_id)
        IF existingLine EXISTS:
            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
            RECALCULATE existingLine amounts
        ELSE:
            MOVE line to customerCart


status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '280,760p' specs/05-BUSINESS-LOGIC.md","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":32000});
text(r.output);