command: vendor/bin/pint --dirty --format agent && php artisan test --compact tests/Feature/ApiTokenTest.php tests/Feature/AdminApiTest.php

stdout:
{"result":"fixed","files":[{"path":"database\/seeders\/TaxSettingsSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/ShippingSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/StoreUserSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/CollectionSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/ProductSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/UserSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/ThemeSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/AnalyticsSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/CustomerSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/NavigationSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/SearchSettingsSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/PageSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/OrderSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/DiscountSeeder.php","fixers":["no_unused_imports"]},{"path":"tests\/Feature\/ApiTokenTest.php","fixers":["no_unused_imports"]}]}
  ⨯⨯..
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\ApiTokenTest > admin bearer tokens can read…   Error
  Class "Store" not found

  at tests/Feature/ApiTokenTest.php:11
      7▕
      8▕ beforeEach(function (): void {
      9▕     config(['cache.default' => 'array']);
     10▕     $this->seed(ShopSeeder::class);
  ➜  11▕     $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
     12▕     $this->admin = User::query()->where('email', 'admin@acme.test')->firstOrFail();
     13▕ });
     14▕
     15▕ test('admin bearer tokens can read only the abilities they were granted', function (): void {

  1   tests/Feature/ApiTokenTest.php:11

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\ApiTokenTest > admin bearer tokens can write…  Error
  Class "Store" not found

  at tests/Feature/ApiTokenTest.php:11
      7▕
      8▕ beforeEach(function (): void {
      9▕     config(['cache.default' => 'array']);
     10▕     $this->seed(ShopSeeder::class);
  ➜  11▕     $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
     12▕     $this->admin = User::query()->where('email', 'admin@acme.test')->firstOrFail();
     13▕ });
     14▕
     15▕ test('admin bearer tokens can read only the abilities they were granted', function (): void {

  1   tests/Feature/ApiTokenTest.php:11

  Tests:    2 failed, 2 passed (5 assertions)
  Duration: 0.26s


text:
Script completed
Wall time 1.3 seconds
Output:

text:
..
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\ApiTokenTest > admin bearer tokens can read…   Error
  Class "Store" not found

  at tests/Feature/ApiTokenTest.php:11
      7▕
      8▕ beforeEach(function (): void {
      9▕     config(['cache.default' => 'array']);
     10▕     $this->seed(ShopSeeder::class);
  ➜  11▕     $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
     12▕     $this->admin = User::query()->where('email', 'admin@acme.test')->firstOrFail();
     13▕ });
     14▕
     15▕ test('admin bearer tokens can read only the abilities they were granted', function (): void {

  1   tests/Feature/ApiTokenTest.php:11

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\ApiTokenTest > admin bearer tokens can write…  Error
  Class "Store" not found

  at tests/Feature/ApiTokenTest.php:11
      7▕
      8▕ beforeEach(function (): void {
      9▕     config(['cache.default' => 'array']);
     10▕     $this->seed(ShopSeeder::class);
  ➜  11▕     $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
     12▕     $this->admin = User::query()->where('email', 'admin@acme.test')->firstOrFail();
     13▕ });
     14▕
     15▕ test('admin bearer tokens can read only the abilities they were granted', function (): void {

  1   tests/Feature/ApiTokenTest.php:11

  Tests:    2 failed, 2 passed (5 assertions)
  Duration: 0.26s


exec

status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,70p' tests/Feature/ApiTokenTest.php; sed -n '1,80p' database/seeders/ShopSeeder.php","workdir":"/Users/wesner/Herd/shop","yield_time_ms":1000,"max_output_tokens":6000});
text(r.output);


type: CommandExecution

command: sed -n '1,70p' tests/Feature/ApiTokenTest.php; sed -n '1,80p' database/seeders/ShopSeeder.php

ApiTokenTest.php

type: read

ShopSeeder.php

status: completed

stdout:
<?php

use Database\Seeders\ShopSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

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

test('admin bearer tokens can read only the abilities they were granted', function (): void {
    $token = $this->admin->createToken('catalog-reader', ['read-products'])->plainTextToken;

    $this->withToken($token)
        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
        ->assertOk();

    $this->withToken($token)
        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Blocked'])
        ->assertForbidden();
});

test('admin bearer tokens can write with the matching ability', function (): void {
    $token = $this->admin->createToken('catalog-manager', ['write-collections'])->plainTextToken;

    $this->withToken($token)
        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Token Collection'])
        ->assertCreated()
        ->assertJsonPath('data.title', 'Token Collection');
});
<?php

namespace Database\Seeders;

use App\Enums\DiscountValueType;
use App\Enums\InventoryPolicy;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Enums\ProductStatus;
use App\Enums\StoreDomainType;
use App\Enums\StoreUserRole;
use App\Models\Collection;
use App\Models\Discount;
use App\Models\InventoryItem;
use App\Models\NavigationItem;
use App\Models\NavigationMenu;
use App\Models\Order;
use App\Models\Organization;
use App\Models\Page;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use App\Models\StoreDomain;
use App\Models\StoreSettings;
use App\Models\TaxSettings;
use App\Models\Theme;
use App\Models\User;
use Illuminate\Database\Seeder;

class ShopSeeder extends Seeder
{
    public function run(): void
    {
        $organization = Organization::firstOrCreate(['slug' => 'acme-commerce'], ['name' => 'Acme Commerce', 'billing_email' => 'billing@acme.test', 'status' => 'active']);
        $store = Store::firstOrCreate(['handle' => 'acme-fashion'], ['organization_id' => $organization->getKey(), 'name' => 'Acme Fashion', 'default_currency' => 'EUR', 'default_locale' => 'en', 'timezone' => 'Europe/Berlin', 'status' => 'active']);
        StoreDomain::firstOrCreate(['hostname' => 'acme-fashion.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Storefront, 'is_primary' => true, 'tls_mode' => 'managed']);
        StoreDomain::firstOrCreate(['hostname' => 'shop.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Storefront, 'is_primary' => false, 'tls_mode' => 'managed']);
        StoreDomain::firstOrCreate(['hostname' => 'admin.acme-fashion.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Admin, 'is_primary' => true, 'tls_mode' => 'managed']);
        StoreSettings::updateOrCreate(['store_id' => $store->getKey()], ['settings_json' => ['announcement' => 'Free shipping on orders over €50', 'hero_heading' => 'Everyday pieces, thoughtfully made.'], 'general_json' => ['store_name' => 'Acme Fashion']]);
        $domestic = ShippingZone::updateOrCreate(['store_id' => $store->getKey(), 'name' => 'Domestic'], ['countries_json' => ['DE'], 'regions_json' => []]);
        ShippingRate::updateOrCreate(['shipping_zone_id' => $domestic->getKey(), 'name' => 'Standard Shipping'], ['type' => 'flat', 'price_amount' => 499, 'currency' => 'EUR', 'is_active' => true, 'estimated_days_min' => 3, 'estimated_days_max' => 5]);
        TaxSettings::updateOrCreate(['store_id' => $store->getKey()], ['mode' => 'exclusive', 'default_rate_basis_points' => 1900, 'rates_json' => ['DE' => 1900]]);
        Theme::firstOrCreate(['store_id' => $store->getKey(), 'name' => 'Acme Default'], ['status' => 'published', 'settings' => ['hero_heading' => 'Everyday pieces, thoughtfully made.']]);
        Page::updateOrCreate(['store_id' => $store->getKey(), 'handle' => 'about'], ['title' => 'About', 'content' => '
Acme Fashion makes thoughtful everyday pieces for modern wardrobes.
', 'status' => 'published', 'published_at' => now()]);
        $mainMenu = NavigationMenu::updateOrCreate(['store_id' => $store->getKey(), 'handle' => 'main'], ['name' => 'Main menu']);
        $mainMenu->items()->delete();
        NavigationItem::create(['navigation_menu_id' => $mainMenu->getKey(), 'label' => 'Collections', 'type' => 'link', 'url' => '/collections', 'position' => 1]);
        NavigationItem::create(['navigation_menu_id' => $mainMenu->getKey(), 'label' => 'About', 'type' => 'link', 'url' => '/pages/about', 'position' => 2]);

        $admin = User::firstOrCreate(['email' => 'admin@acme.test'], ['name' => 'Acme Admin', 'password' => 'password', 'email_verified_at' => now(), 'status' => 'active']);
        $store->users()->syncWithoutDetaching([$admin->getKey() => ['role' => StoreUserRole::Owner->value]]);
        $customer = \App\Models\Customer::firstOrCreate(['store_id' => $store->getKey(), 'email' => 'customer@acme.test'], ['first_name' => 'Jamie', 'last_name' => 'Customer', 'password_hash' => 'password', 'email_verified_at' => now(), 'status' => 'active']);

        $tShirts = Collection::firstOrCreate(['store_id' => $store->getKey(), 'handle' => 't-shirts'], ['title' => 'T-Shirts', 'description' => 'Soft, everyday essentials.', 'status' => 'active']);
        $newArrivals = Collection::firstOrCreate(['store_id' => $store->getKey(), 'handle' => 'new-arrivals'], ['title' => 'New Arrivals', 'description' => 'Fresh pieces for the season.', 'status' => 'active']);
        $classic = $this->product($store, 'Classic Cotton T-Shirt', 'classic-cotton-t-shirt', 2499, 80, InventoryPolicy::Deny, ['S', 'M', 'L', 'XL'], ['Black', 'White', 'Navy']);
        $jeans = $this->product($store, 'Premium Slim Fit Jeans', 'premium-slim-fit-jeans', 7999, 35, InventoryPolicy::Deny, ['28', '30', '32', '34'], ['Indigo']);
        $draft = $this->product($store, 'Coming Soon Jacket', 'coming-soon-jacket', 12999, 0, InventoryPolicy::Deny, ['M'], ['Black'], ProductStatus::Draft);
        $soldOut = $this->product($store, 'Sold Out Limited Tee', 'sold-out-limited-tee', 3999, 0, InventoryPolicy::Deny, ['M'], ['White']);
        $backorder = $this->product($store, 'Relaxed Backorder Hoodie', 'relaxed-backorder-hoodie', 6999, 0, InventoryPolicy::Continue, ['M', 'L'], ['Navy']);
        $tShirts->products()->syncWithoutDetaching([$classic->getKey() => ['position' => 1], $soldOut->getKey() => ['position' => 2]]);
        $newArrivals->products()->syncWithoutDetaching([$classic->getKey() => ['position' => 1], $jeans->getKey() => ['position' => 2], $backorder->getKey() => ['position' => 3]]);

        foreach ([
            ['code' => 'WELCOME10', 'value_type' => DiscountValueType::Percent, 'value_amount' => 10],
            ['code' => 'FLAT5', 'value_type' => DiscountValueType::Fixed, 'value_amount' => 500],
            ['code' => 'FREESHIP', 'value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0],
            ['code' => 'EXPIRED20', 'value_type' => DiscountValueType::Percent, 'value_amount' => 20, 'ends_at' => now()->subDay()],
            ['code' => 'MAXED', 'value_type' => DiscountValueType::Percent, 'value_amount' => 15, 'usage_limit' => 1, 'usage_count' => 1],
        ] as $discount) {
            Discount::updateOrCreate(['store_id' => $store->getKey(), 'code' => $discount['code']], array_merge(['type' => 'code', 'status' => 'active', 'starts_at' => now()->subDay(), 'ends_at' => now()->addMonth(), 'usage_limit' => null, 'usage_count' => 0, 'rules_json' => []], $discount));
        }

        $order = Order::withoutGlobalScopes()->firstOrCreate(['store_id' => $store->getKey(), 'order_number' => '#1001'], ['customer_id' => $customer->getKey(), 'currency' => 'EUR', 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', 'payment_method' => PaymentMethod::CreditCard, 'email' => $customer->email, 'subtotal_amount' => 2499, 'shipping_amount' => 499, 'tax_amount' => 0, 'total_amount' => 2998, 'placed_at' => now()->subDay()]);
        if ($order->lines()->count() === 0) {
            $variant = $classic->variants()->first();
            $order->lines()->create(['product_id' => $classic->getKey(), 'variant_id' => $variant->getKey(), 'product_title' => $classic->title, 'variant_title' => $variant->title, 'sku' => $variant->sku, 'quantity' => 1, 'unit_price_amount' => $variant->price_amount, 'line_subtotal_amount' => $variant->price_amount, 'line_total_amount' => $variant->price_amount]);
            $order->payments()->create(['provider' => 'mock', 'provider_payment_id' => 'mock_seed_1001', 'method' => PaymentMethod::CreditCard, 'status' => PaymentStatus::Captured, 'amount' => $order->total_amount]);
        }


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

use Database\Seeders\ShopSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

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

test('admin bearer tokens can read only the abilities they were granted', function (): void {
    $token = $this->admin->createToken('catalog-reader', ['read-products'])->plainTextToken;

    $this->withToken($token)
        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
        ->assertOk();

    $this->withToken($token)
        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Blocked'])
        ->assertForbidden();
});

test('admin bearer tokens can write with the matching ability', function (): void {
    $token = $this->admin->createToken('catalog-manager', ['write-collections'])->plainTextToken;

    $this->withToken($token)
        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Token Collection'])
        ->assertCreated()
        ->assertJsonPath('data.title', 'Token Collection');
});
<?php

namespace Database\Seeders;

use App\Enums\DiscountValueType;
use App\Enums\InventoryPolicy;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Enums\ProductStatus;
use App\Enums\StoreDomainType;
use App\Enums\StoreUserRole;
use App\Models\Collection;
use App\Models\Discount;
use App\Models\InventoryItem;
use App\Models\NavigationItem;
use App\Models\NavigationMenu;
use App\Models\Order;
use App\Models\Organization;
use App\Models\Page;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use App\Models\StoreDomain;
use App\Models\StoreSettings;
use App\Models\TaxSettings;
use App\Models\Theme;
use App\Models\User;
use Illuminate\Database\Seeder;

class ShopSeeder extends Seeder
{
    public function run(): void
    {
        $organization = Organization::firstOrCreate(['slug' => 'acme-commerce'], ['name' => 'Acme Commerce', 'billing_email' => 'billing@acme.test', 'status' => 'active']);
        $store = Store::firstOrCreate(['handle' => 'acme-fashion'], ['organization_id' => $organization->getKey(), 'name' => 'Acme Fashion', 'default_currency' => 'EUR', 'default_locale' => 'en', 'timezone' => 'Europe/Berlin', 'status' => 'active']);
        StoreDomain::firstOrCreate(['hostname' => 'acme-fashion.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Storefront, 'is_primary' => true, 'tls_mode' => 'managed']);
        StoreDomain::firstOrCreate(['hostname' => 'shop.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Storefront, 'is_primary' => false, 'tls_mode' => 'managed']);
        StoreDomain::firstOrCreate(['hostname' => 'admin.acme-fashion.test'], ['store_id' => $store->getKey(), 'type' => StoreDomainType::Admin, 'is_primary' => true, 'tls_mode' => 'managed']);
        StoreSettings::updateOrCreate(['store_id' => $store->getKey()], ['settings_json' => ['announcement' => 'Free shipping on orders over €50', 'hero_heading' => 'Everyday pieces, thoughtfully made.'], 'general_json' => ['store_name' => 'Acme Fashion']]);
        $domestic = ShippingZone::updateOrCreate(['store_id' => $store->getKey(), 'name' => 'Domestic'], ['countries_json' => ['DE'], 'regions_json' => []]);
        ShippingRate::updateOrCreate(['shipping_zone_id' => $domestic->getKey(), 'name' => 'Standard Shipping'], ['type' => 'flat', 'price_amount' => 499, 'currency' => 'EUR', 'is_active' => true, 'estimated_days_min' => 3, 'estimated_days_max' => 5]);
        TaxSettings::updateOrCreate(['store_id' => $store->getKey()], ['mode' => 'exclusive', 'default_rate_basis_points' => 1900, 'rates_json' => ['DE' => 1900]]);
        Theme::firstOrCreate(['store_id' => $store->getKey(), 'name' => 'Acme Default'], ['status' => 'published', 'settings' => ['hero_heading' => 'Everyday pieces, thoughtfully made.']]);
        Page::updateOrCreate(['store_id' => $store->getKey(), 'handle' => 'about'], ['title' => 'About', 'content' => '
Acme Fashion makes thoughtful everyday pieces for modern wardrobes.
', 'status' => 'published', 'published_at' => now()]);
        $mainMenu = NavigationMenu::updateOrCreate(['store_id' => $store->getKey(), 'handle' => 'main'], ['name' => 'Main menu']);
        $mainMenu->items()->delete();
        NavigationItem::create(['navigation_menu_id' => $mainMenu->getKey(), 'label' => 'Collections', 'type' => 'link', 'url' => '/collections', 'position' => 1]);
        NavigationItem::create(['navigation_menu_id' => $mainMenu->getKey(), 'label' => 'About', 'type' => 'link', 'url' => '/pages/about', 'position' => 2]);

        $admin = User::firstOrCreate(['email' => 'admin@acme.test'], ['name' => 'Acme Admin', 'password' => 'password', 'email_verified_at' => now(), 'status' => 'active']);
        $store->users()->syncWithoutDetaching([$admin->getKey() => ['role' => StoreUserRole::Owner->value]]);
        $customer = \App\Models\Customer::firstOrCreate(['store_id' => $store->getKey(), 'email' => 'customer@acme.test'], ['first_name' => 'Jamie', 'last_name' => 'Customer', 'password_hash' => 'password', 'email_verified_at' => now(), 'status' => 'active']);

        $tShirts = Collection::firstOrCreate(['store_id' => $store->getKey(), 'handle' => 't-shirts'], ['title' => 'T-Shirts', 'description' => 'Soft, everyday essentials.', 'status' => 'active']);
        $newArrivals = Collection::firstOrCreate(['store_id' => $store->getKey(), 'handle' => 'new-arrivals'], ['title' => 'New Arrivals', 'description' => 'Fresh pieces for the season.', 'status' => 'active']);
        $classic = $this->product($store, 'Classic Cotton T-Shirt', 'classic-cotton-t-shirt', 2499, 80, InventoryPolicy::Deny, ['S', 'M', 'L', 'XL'], ['Black', 'White', 'Navy']);
        $jeans = $this->product($store, 'Premium Slim Fit Jeans', 'premium-slim-fit-jeans', 7999, 35, InventoryPolicy::Deny, ['28', '30', '32', '34'], ['Indigo']);
        $draft = $this->product($store, 'Coming Soon Jacket', 'coming-soon-jacket', 12999, 0, InventoryPolicy::Deny, ['M'], ['Black'], ProductStatus::Draft);
        $soldOut = $this->product($store, 'Sold Out Limited Tee', 'sold-out-limited-tee', 3999, 0, InventoryPolicy::Deny, ['M'], ['White']);
        $backorder = $this->product($store, 'Relaxed Backorder Hoodie', 'relaxed-backorder-hoodie', 6999, 0, InventoryPolicy::Continue, ['M', 'L'], ['Navy']);
        $tShirts->products()->syncWithoutDetaching([$classic->getKey() => ['position' => 1], $soldOut->getKey() => ['position' => 2]]);
        $newArrivals->products()->syncWithoutDetaching([$classic->getKey() => ['position' => 1], $jeans->getKey() => ['position' => 2], $backorder->getKey() => ['position' => 3]]);

        foreach ([
            ['code' => 'WELCOME10', 'value_type' => DiscountValueType::Percent, 'value_amount' => 10],
            ['code' => 'FLAT5', 'value_type' => DiscountValueType::Fixed, 'value_amount' => 500],
            ['code' => 'FREESHIP', 'value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0],
            ['code' => 'EXPIRED20', 'value_type' => DiscountValueType::Percent, 'value_amount' => 20, 'ends_at' => now()->subDay()],
            ['code' => 'MAXED', 'value_type' => DiscountValueType::Percent, 'value_amount' => 15, 'usage_limit' => 1, 'usage_count' => 1],
        ] as $discount) {
            Discount::updateOrCreate(['store_id' => $store->getKey(), 'code' => $discount['code']], array_merge(['type' => 'code', 'status' => 'active', 'starts_at' => now()->subDay(), 'ends_at' => now()->addMonth(), 'usage_limit' => null, 'usage_count' => 0, 'rules_json' => []], $discount));
        }

        $order = Order::withoutGlobalScopes()->firstOrCreate(['store_id' => $store->getKey(), 'order_number' => '#1001'], ['customer_id' => $customer->getKey(), 'currency' => 'EUR', 'status' => 'paid', 'financial_status' => 'paid', 'fulfillment_status' => 'unfulfilled', 'payment_method' => PaymentMethod::CreditCard, 'email' => $customer->email, 'subtotal_amount' => 2499, 'shipping_amount' => 499, 'tax_amount' => 0, 'total_amount' => 2998, 'placed_at' => now()->subDay()]);
        if ($order->lines()->count() === 0) {
            $variant = $classic->variants()->first();
            $order->lines()->create(['product_id' => $classic->getKey(), 'variant_id' => $variant->getKey(), 'product_title' => $classic->title, 'variant_title' => $variant->title, 'sku' => $variant->sku, 'quantity' => 1, 'unit_price_amount' => $variant->price_amount, 'line_subtotal_amount' => $variant->price_amount, 'line_total_amount' => $variant->price_amount]);
            $order->payments()->create(['provider' => 'mock', 'provider_payment_id' => 'mock_seed_1001', 'method' => PaymentMethod::CreditCard, 'status' => PaymentStatus::Captured, 'amount' => $order->total_amount]);
        }


exec

status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/tests/Feature/ApiTokenTest.php\n@@\n <?php\n \n+use App\\Models\\Store;\n+use App\\Models\\User;\n use Database\\Seeders\\ShopSeeder;\n*** End Patch";
text(await tools.apply_patch(patch));


type: FileChange

type: update

unified_diff:
@@ -2,2 +2,4 @@

+use App\Models\Store;
+use App\Models\User;
 use Database\Seeders\ShopSeeder;

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


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input:
const r = await tools.exec_command({cmd:"php artisan test --compact tests/Feature/ApiTokenTest.php tests/Feature/AdminApiTest.php","workdir":"/Users/wesner/Herd/shop","yield_time_ms":1000,"max_output_tokens":12000});
text(r.output);
if (r.session_id) text((await tools.write_stdin({session_id:r.session_id, chars:"", yield_time_ms:1000, max_output_tokens:12000})).output);


command: php artisan test --compact tests/Feature/ApiTokenTest.php tests/Feature/AdminApiTest.php

type: unknown

stdout:
  ....

  Tests:    4 passed (9 assertions)
  Duration: 0.26s


text:
Script completed
Wall time 0.5 seconds
Output:

text:
  ....

  Tests:    4 passed (9 assertions)
  Duration: 0.26s


status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,260p' specs/01-DATABASE-SCHEMA.md; sed -n '260,620p' specs/01-DATABASE-SCHEMA.md","workdir":"/Users/wesner/Herd/shop","yield_time_ms":1000,"max_output_tokens":26000});
text(r.output);


command: sed -n '1,260p' specs/01-DATABASE-SCHEMA.md; sed -n '260,620p' specs/01-DATABASE-SCHEMA.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 |
| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |
| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |

**Primary Key:** (`variant_id`, `product_option_value_id`)

**Indexes:**

- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value

**Notes:** This is a junction table that links variants to the specific option values they represent. Required to resolve which combination of options each variant corresponds to.

---

### inventory_items

Stock tracking per variant per 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 |
| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |
| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |
| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |
| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |

**Indexes:**

- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup
- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store

**Notes:** `quantity_on_hand` is the total physical stock. `quantity_reserved` is stock allocated to open orders not yet fulfilled. Available = on_hand - reserved. Policy `deny` prevents overselling; `continue` allows selling below zero.

---

### collections

Curated or rule-based product groupings.

| 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 | - | - | Collection display title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| description_html | TEXT | Yes | NULL | - | Rich-text description |
| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |
| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store
- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status

---

### collection_products

Pivot linking products to collections with ordering. Composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |
| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |
| position | INTEGER | No | 0 | - | Display ordering within collection |

**Primary Key:** (`collection_id`, `product_id`)

**Indexes:**

- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product
- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection

---

### product_media

Images and videos attached to 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 |
| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |
| storage_key | TEXT | No | - | - | File storage path/key |
| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |
| width | INTEGER | Yes | NULL | - | Pixel width |
| height | INTEGER | Yes | NULL | - | Pixel height |
| mime_type | TEXT | Yes | NULL | - | e.g. "image/jpeg" |
| byte_size | INTEGER | Yes | NULL | - | File size in bytes |
| position | INTEGER | No | 0 | - | Display ordering |
| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product
- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing
- `idx_product_media_status` on (`status`) - Finding media by processing status

---

## Epic 3 - Themes, Storefront, and Localization

### themes

Storefront theme definitions per 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 |
| name | TEXT | No | - | - | Theme display name |
| version | TEXT | Yes | NULL | - | Semantic version string |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store
- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store

---

### theme_files

Individual files belonging to a theme (templates, assets, snippets).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |
| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |
| storage_key | TEXT | No | - | - | File storage path/key |
| sha256 | TEXT | No | - | - | Content hash for cache busting |
| byte_size | INTEGER | No | 0 | - | File size in bytes |

**Indexes:**

- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme
- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme

---

### theme_settings

Single-row JSON configuration per theme.

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

---

### pages

Static CMS pages (About Us, Contact, etc.).

| 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 | - | - | Page title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| body_html | TEXT | Yes | NULL | - | Rich-text content |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store
- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status

---

### navigation_menus

Named menus (e.g. main-menu, footer-menu).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. "main-menu" |
| title | TEXT | No | - | - | Display title |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store
- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store

---

### navigation_items

Individual links within a navigation menu (supports nesting via position).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |
| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |
| label | TEXT | No | - | - | Display label |
| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |
| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |
| position | INTEGER | No | 0 | - | Display ordering |

**Indexes:**

- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu
- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing

**Notes:** When `type` is `link`, the `url` field is used directly. When `type` is `page`, `collection`, or `product`, the `resource_id` points to the respective table's `id`. The URL is resolved at render time.

---

## Epic 4 - Search

### search_settings

Per-store search configuration (synonyms, stop words).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |
| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

---

### search_queries

Search query log for analytics and autocomplete.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| query | TEXT | No | - | - | Search query text |
| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |
| results_count | INTEGER | No | 0 | - | Number of results returned |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_search_queries_store_id` on (`store_id`) - FK lookup
- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store
- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups

---

## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes

### carts

Shopping cart, optionally linked to a customer.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |
| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_carts_store_id` on (`store_id`) - FK lookup
- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer
- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)

**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.

---

### cart_lines

Individual line items within a cart.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |
| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |
| quantity | INTEGER | No | 1 | - | Number of units |
| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |
| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |
| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |
| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |

**Indexes:**

- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart
- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart

**Notes:** All `_amount` columns are in minor units (cents). `line_subtotal_amount = unit_price_amount * quantity`. `line_total_amount = line_subtotal_amount - line_discount_amount`.

---

### checkouts

Checkout session progressing through address, shipping, payment steps.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |
| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |
| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |
| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |
| email | TEXT | Yes | NULL | - | Contact email |
| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |
| billing_address_json | TEXT | Yes | NULL | - | JSON address object |
| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |
| discount_code | TEXT | Yes | NULL | - | Applied discount code |
| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |
| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |
| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_checkouts_store_id` on (`store_id`) - FK lookup
- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart
- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer
- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status
- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts

**Notes:** `totals_json` contains a snapshot: `{"subtotal": 5000, "discount": 500, "shipping": 799, "tax": 430, "total": 5729}`. `tax_provider_snapshot_json` stores the full tax calculation response for audit purposes.

---

### shipping_zones

Geographic shipping zones for 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 |
| name | TEXT | No | - | - | Zone display name |


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 |
| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |
| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |

**Primary Key:** (`variant_id`, `product_option_value_id`)

**Indexes:**

- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value

**Notes:** This is a junction table that links variants to the specific option values they represent. Required to resolve which combination of options each variant corresponds to.

---

### inventory_items

Stock tracking per variant per 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 |
| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |
| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |
| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |
| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |

**Indexes:**

- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup
- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store

**Notes:** `quantity_on_hand` is the total physical stock. `quantity_reserved` is stock allocated to open orders not yet fulfilled. Available = on_hand - reserved. Policy `deny` prevents overselling; `continue` allows selling below zero.

---

### collections

Curated or rule-based product groupings.

| 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 | - | - | Collection display title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| description_html | TEXT | Yes | NULL | - | Rich-text description |
| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |
| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store
- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status

---

### collection_products

Pivot linking products to collections with ordering. Composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |
| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |
| position | INTEGER | No | 0 | - | Display ordering within collection |

**Primary Key:** (`collection_id`, `product_id`)

**Indexes:**

- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product
- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection

---

### product_media

Images and videos attached to 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 |
| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |
| storage_key | TEXT | No | - | - | File storage path/key |
| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |
| width | INTEGER | Yes | NULL | - | Pixel width |
| height | INTEGER | Yes | NULL | - | Pixel height |
| mime_type | TEXT | Yes | NULL | - | e.g. "image/jpeg" |
| byte_size | INTEGER | Yes | NULL | - | File size in bytes |
| position | INTEGER | No | 0 | - | Display ordering |
| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product
- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing
- `idx_product_media_status` on (`status`) - Finding media by processing status

---

## Epic 3 - Themes, Storefront, and Localization

### themes

Storefront theme definitions per 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 |
| name | TEXT | No | - | - | Theme display name |
| version | TEXT | Yes | NULL | - | Semantic version string |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store
- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store

---

### theme_files

Individual files belonging to a theme (templates, assets, snippets).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |
| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |
| storage_key | TEXT | No | - | - | File storage path/key |
| sha256 | TEXT | No | - | - | Content hash for cache busting |
| byte_size | INTEGER | No | 0 | - | File size in bytes |

**Indexes:**

- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme
- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme

---

### theme_settings

Single-row JSON configuration per theme.

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

---

### pages

Static CMS pages (About Us, Contact, etc.).

| 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 | - | - | Page title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| body_html | TEXT | Yes | NULL | - | Rich-text content |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store
- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status

---

### navigation_menus

Named menus (e.g. main-menu, footer-menu).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. "main-menu" |
| title | TEXT | No | - | - | Display title |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store
- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store

---

### navigation_items

Individual links within a navigation menu (supports nesting via position).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |
| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |
| label | TEXT | No | - | - | Display label |
| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |
| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |
| position | INTEGER | No | 0 | - | Display ordering |

**Indexes:**

- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu
- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing

**Notes:** When `type` is `link`, the `url` field is used directly. When `type` is `page`, `collection`, or `product`, the `resource_id` points to the respective table's `id`. The URL is resolved at render time.

---

## Epic 4 - Search

### search_settings

Per-store search configuration (synonyms, stop words).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |
| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

---

### search_queries

Search query log for analytics and autocomplete.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| query | TEXT | No | - | - | Search query text |
| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |
| results_count | INTEGER | No | 0 | - | Number of results returned |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_search_queries_store_id` on (`store_id`) - FK lookup
- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store
- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups

---

## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes

### carts

Shopping cart, optionally linked to a customer.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |
| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_carts_store_id` on (`store_id`) - FK lookup
- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer
- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)

**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.

---

### cart_lines

Individual line items within a cart.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |
| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |
| quantity | INTEGER | No | 1 | - | Number of units |
| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |
| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |
| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |
| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |

**Indexes:**

- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart
- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart

**Notes:** All `_amount` columns are in minor units (cents). `line_subtotal_amount = unit_price_amount * quantity`. `line_total_amount = line_subtotal_amount - line_discount_amount`.

---

### checkouts

Checkout session progressing through address, shipping, payment steps.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |
| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |
| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |
| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |
| email | TEXT | Yes | NULL | - | Contact email |
| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |
| billing_address_json | TEXT | Yes | NULL | - | JSON address object |
| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |
| discount_code | TEXT | Yes | NULL | - | Applied discount code |
| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |
| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |
| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_checkouts_store_id` on (`store_id`) - FK lookup
- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart
- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer
- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status
- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts

**Notes:** `totals_json` contains a snapshot: `{"subtotal": 5000, "discount": 500, "shipping": 799, "tax": 430, "total": 5729}`. `tax_provider_snapshot_json` stores the full tax calculation response for audit purposes.

---

### shipping_zones

Geographic shipping zones for 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 |
| name | TEXT | No | - | - | Zone display name |


status: completed

input:
const r = await tools.exec_command({cmd:"rg -n \"Schema::create\\('(collections|store_settings|navigation_menus|navigation_items|search_queries|customers|themes|theme_settings|refunds|oauth_clients|oauth_tokens|webhook_subscriptions)'|description_html|settings_json|marketing_opt_in|filters_json|title_snapshot|total_amount\" database/migrations app/Models | head -240","workdir":"/Users/wesner/Herd/shop","yield_time_ms":1000,"max_output_tokens":16000});
text(r.output);


type: search

path: migrations

stdout:
app/Models/StoreSettings.php:21:    protected $fillable = ['store_id', 'settings_json', 'general_json', 'checkout_json', 'notification_json', 'social_json'];
app/Models/StoreSettings.php:24:        'settings_json' => '{}',
app/Models/StoreSettings.php:29:        return ['settings_json' => 'array', 'general_json' => 'array', 'checkout_json' => 'array', 'notification_json' => 'array', 'social_json' => 'array'];
database/migrations/2026_08_20_000000_create_shop_schema.php:60:        Schema::create('store_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:143:        Schema::create('collections', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:179:        Schema::create('themes', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:199:        Schema::create('theme_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:220:        Schema::create('navigation_menus', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:229:        Schema::create('navigation_items', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:250:        Schema::create('search_queries', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:313:        Schema::create('customers', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:358:            $table->unsignedInteger('line_subtotal_amount');
database/migrations/2026_08_20_000000_create_shop_schema.php:360:            $table->unsignedInteger('line_total_amount');
database/migrations/2026_08_20_000000_create_shop_schema.php:400:            $table->unsignedInteger('subtotal_amount')->default(0);
database/migrations/2026_08_20_000000_create_shop_schema.php:404:            $table->unsignedInteger('total_amount')->default(0);
database/migrations/2026_08_20_000000_create_shop_schema.php:424:            $table->unsignedInteger('line_subtotal_amount');
database/migrations/2026_08_20_000000_create_shop_schema.php:426:            $table->unsignedInteger('line_total_amount');
database/migrations/2026_08_20_000000_create_shop_schema.php:447:        Schema::create('refunds', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:528:        Schema::create('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:538:        Schema::create('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:549:        Schema::create('webhook_subscriptions', function (Blueprint $table): void {
database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php:15:            $table->text('settings_json')->default('{}')->after('store_id');
database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php:25:            $table->dropColumn('settings_json');
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:12:            $table->longText('description_html')->nullable();
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:38:            $table->string('title_snapshot')->nullable();
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:52:            $table->dropColumn(['title_snapshot', 'sku_snapshot']);
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:72:            $table->dropColumn('description_html');
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:59:            if (! Schema::hasColumn('order_lines', 'total_amount')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:60:                $table->unsignedInteger('total_amount')->nullable();
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:113:        foreach (['password_hash' => 'users', 'store_id' => 'customer_password_reset_tokens', 'width' => 'product_media', 'height' => 'product_media', 'storage_key' => 'theme_files', 'sha256' => 'theme_files', 'byte_size' => 'theme_files', 'body_html' => 'pages', 'menu_id' => 'navigation_items', 'synonyms_json' => 'search_settings', 'stop_words_json' => 'search_settings', 'total_amount' => 'order_lines', 'currency' => 'payments', 'properties_json' => 'analytics_events', 'occurred_at' => 'analytics_events', 'checkout_completed_count' => 'analytics_daily', 'event_type' => 'webhook_subscriptions', 'app_installation_id' => 'webhook_subscriptions', 'subscription_id' => 'webhook_deliveries', 'event_id' => 'webhook_deliveries', 'attempt_count' => 'webhook_deliveries', 'last_attempt_at' => 'webhook_deliveries', 'response_code' => 'webhook_deliveries', 'response_body_snippet' => 'webhook_deliveries'] as $column => $tableName) {
app/Models/Order.php:17:    protected $fillable = ['store_id', 'customer_id', 'checkout_id', 'order_number', 'currency', 'status', 'financial_status', 'fulfillment_status', 'payment_method', 'email', 'shipping_address_json', 'billing_address_json', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'placed_at', 'metadata'];
app/Models/Product.php:16:    protected $fillable = ['store_id', 'title', 'handle', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at', 'sales_count', 'metadata'];
app/Models/OrderLine.php:10:    protected $fillable = ['order_id', 'product_id', 'variant_id', 'product_title', 'title_snapshot', 'variant_title', 'sku', 'sku_snapshot', 'quantity', 'unit_price_amount', 'line_subtotal_amount', 'line_discount_amount', 'line_total_amount', 'total_amount', 'tax_lines_json', 'discount_allocations_json'];
app/Models/OrderLine.php:20:            if ($line->isDirty('total_amount') && ! $line->isDirty('line_total_amount')) {
app/Models/OrderLine.php:21:                $line->line_total_amount = $line->total_amount;
app/Models/OrderLine.php:23:            $line->total_amount = $line->line_total_amount;
app/Models/Cart.php:34:        return (int) $this->lines->sum('line_total_amount');
app/Models/CartLine.php:10:    protected $fillable = ['cart_id', 'variant_id', 'quantity', 'unit_price_amount', 'line_subtotal_amount', 'line_discount_amount', 'line_total_amount'];


text:
Script completed
Wall time 0.1 seconds
Output:

text:
app/Models/StoreSettings.php:21:    protected $fillable = ['store_id', 'settings_json', 'general_json', 'checkout_json', 'notification_json', 'social_json'];
app/Models/StoreSettings.php:24:        'settings_json' => '{}',
app/Models/StoreSettings.php:29:        return ['settings_json' => 'array', 'general_json' => 'array', 'checkout_json' => 'array', 'notification_json' => 'array', 'social_json' => 'array'];
database/migrations/2026_08_20_000000_create_shop_schema.php:60:        Schema::create('store_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:143:        Schema::create('collections', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:179:        Schema::create('themes', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:199:        Schema::create('theme_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:220:        Schema::create('navigation_menus', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:229:        Schema::create('navigation_items', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:250:        Schema::create('search_queries', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:313:        Schema::create('customers', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:358:            $table->unsignedInteger('line_subtotal_amount');
database/migrations/2026_08_20_000000_create_shop_schema.php:360:            $table->unsignedInteger('line_total_amount');
database/migrations/2026_08_20_000000_create_shop_schema.php:400:            $table->unsignedInteger('subtotal_amount')->default(0);
database/migrations/2026_08_20_000000_create_shop_schema.php:404:            $table->unsignedInteger('total_amount')->default(0);
database/migrations/2026_08_20_000000_create_shop_schema.php:424:            $table->unsignedInteger('line_subtotal_amount');
database/migrations/2026_08_20_000000_create_shop_schema.php:426:            $table->unsignedInteger('line_total_amount');
database/migrations/2026_08_20_000000_create_shop_schema.php:447:        Schema::create('refunds', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:528:        Schema::create('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:538:        Schema::create('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:549:        Schema::create('webhook_subscriptions', function (Blueprint $table): void {
database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php:15:            $table->text('settings_json')->default('{}')->after('store_id');
database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php:25:            $table->dropColumn('settings_json');
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:12:            $table->longText('description_html')->nullable();
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:38:            $table->string('title_snapshot')->nullable();
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:52:            $table->dropColumn(['title_snapshot', 'sku_snapshot']);
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:72:            $table->dropColumn('description_html');
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:59:            if (! Schema::hasColumn('order_lines', 'total_amount')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:60:                $table->unsignedInteger('total_amount')->nullable();
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:113:        foreach (['password_hash' => 'users', 'store_id' => 'customer_password_reset_tokens', 'width' => 'product_media', 'height' => 'product_media', 'storage_key' => 'theme_files', 'sha256' => 'theme_files', 'byte_size' => 'theme_files', 'body_html' => 'pages', 'menu_id' => 'navigation_items', 'synonyms_json' => 'search_settings', 'stop_words_json' => 'search_settings', 'total_amount' => 'order_lines', 'currency' => 'payments', 'properties_json' => 'analytics_events', 'occurred_at' => 'analytics_events', 'checkout_completed_count' => 'analytics_daily', 'event_type' => 'webhook_subscriptions', 'app_installation_id' => 'webhook_subscriptions', 'subscription_id' => 'webhook_deliveries', 'event_id' => 'webhook_deliveries', 'attempt_count' => 'webhook_deliveries', 'last_attempt_at' => 'webhook_deliveries', 'response_code' => 'webhook_deliveries', 'response_body_snippet' => 'webhook_deliveries'] as $column => $tableName) {
app/Models/Order.php:17:    protected $fillable = ['store_id', 'customer_id', 'checkout_id', 'order_number', 'currency', 'status', 'financial_status', 'fulfillment_status', 'payment_method', 'email', 'shipping_address_json', 'billing_address_json', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'placed_at', 'metadata'];
app/Models/Product.php:16:    protected $fillable = ['store_id', 'title', 'handle', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at', 'sales_count', 'metadata'];
app/Models/OrderLine.php:10:    protected $fillable = ['order_id', 'product_id', 'variant_id', 'product_title', 'title_snapshot', 'variant_title', 'sku', 'sku_snapshot', 'quantity', 'unit_price_amount', 'line_subtotal_amount', 'line_discount_amount', 'line_total_amount', 'total_amount', 'tax_lines_json', 'discount_allocations_json'];
app/Models/OrderLine.php:20:            if ($line->isDirty('total_amount') && ! $line->isDirty('line_total_amount')) {
app/Models/OrderLine.php:21:                $line->line_total_amount = $line->total_amount;
app/Models/OrderLine.php:23:            $line->total_amount = $line->line_total_amount;
app/Models/Cart.php:34:        return (int) $this->lines->sum('line_total_amount');
app/Models/CartLine.php:10:    protected $fillable = ['cart_id', 'variant_id', 'quantity', 'unit_price_amount', 'line_subtotal_amount', 'line_discount_amount', 'line_total_amount'];


status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '130,245p' database/migrations/2026_08_20_000000_create_shop_schema.php; sed -n '300,470p' database/migrations/2026_08_20_000000_create_shop_schema.php; sed -n '510,575p' database/migrations/2026_08_20_000000_create_shop_schema.php; sed -n '1,150p' database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php; sed -n '1,180p' database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php","workdir":"/Users/wesner/Herd/shop","yield_time_ms":1000,"max_output_tokens":22000});
text(r.output);