     1→<?php
     2→
     3→/*
     4→|--------------------------------------------------------------------------
     5→| Test Case
     6→|--------------------------------------------------------------------------
     7→|
     8→| The closure you provide to your test functions is always bound to a specific PHPUnit test
     9→| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
    10→| need to change it using the "pest()" function to bind a different classes or traits.
    11→|
    12→*/
    13→
    14→pest()->extend(Tests\TestCase::class)
    15→ // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    16→    ->in('Feature');
    17→
    18→/*
    19→|--------------------------------------------------------------------------
    20→| Expectations
    21→|--------------------------------------------------------------------------
    22→|
    23→| When you're writing tests, you often need to check that values meet certain conditions. The
    24→| "expect()" function gives you access to a set of "expectations" methods that you can use
    25→| to assert different things. Of course, you may extend the Expectation API at any time.
    26→|
    27→*/
    28→
    29→expect()->extend('toBeOne', function () {
    30→    return $this->toBe(1);
    31→});
    32→
    33→/*
    34→|--------------------------------------------------------------------------
    35→| Functions
    36→|--------------------------------------------------------------------------
    37→|
    38→| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
    39→| project that you don't want to repeat in every file. Here you can also expose helpers as
    40→| global functions to help you to reduce the number of lines of code in your test files.
    41→|
    42→*/
    43→
    44→function something()
    45→{
    46→    // ..
    47→}
    48→

<?php

/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/

pest()->extend(Tests\TestCase::class)
 // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature');

/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/

expect()->extend('toBeOne', function () {
    return $this->toBe(1);
});

/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/

function something()
{
    // ..
}


     1→<?php
     2→
     3→use Illuminate\Foundation\Inspiring;
     4→use Illuminate\Support\Facades\Artisan;
     5→
     6→Artisan::command('inspire', function () {
     7→    $this->comment(Inspiring::quote());
     8→})->purpose('Display an inspiring quote');
     9→

<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');


     1→<?php
     2→
     3→namespace App\Providers;
     4→
     5→use Carbon\CarbonImmutable;
     6→use Illuminate\Support\Facades\Date;
     7→use Illuminate\Support\Facades\DB;
     8→use Illuminate\Support\ServiceProvider;
     9→use Illuminate\Validation\Rules\Password;
    10→
    11→class AppServiceProvider extends ServiceProvider
    12→{
    13→    /**
    14→     * Register any application services.
    15→     */
    16→    public function register(): void
    17→    {
    18→        //
    19→    }
    20→
    21→    /**
    22→     * Bootstrap any application services.
    23→     */
    24→    public function boot(): void
    25→    {
    26→        $this->configureDefaults();
    27→    }
    28→
    29→    /**
    30→     * Configure default behaviors for production-ready applications.
    31→     */
    32→    protected function configureDefaults(): void
    33→    {
    34→        Date::use(CarbonImmutable::class);
    35→
    36→        DB::prohibitDestructiveCommands(
    37→            app()->isProduction(),
    38→        );
    39→
    40→        Password::defaults(fn (): ?Password => app()->isProduction()
    41→            ? Password::min(12)
    42→                ->mixedCase()
    43→                ->letters()
    44→                ->numbers()
    45→                ->symbols()
    46→                ->uncompromised()
    47→            : null
    48→        );
    49→    }
    50→}
    51→

<?php

namespace App\Providers;

use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureDefaults();
    }

    /**
     * Configure default behaviors for production-ready applications.
     */
    protected function configureDefaults(): void
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null
        );
    }
}


     1→<?php
     2→
     3→use Illuminate\Support\Str;
     4→
     5→return [
     6→
     7→    /*
     8→    |--------------------------------------------------------------------------
     9→    | Default Database Connection Name
    10→    |--------------------------------------------------------------------------
    11→    |
    12→    | Here you may specify which of the database connections below you wish
    13→    | to use as your default connection for database operations. This is
    14→    | the connection which will be utilized unless another connection
    15→    | is explicitly specified when you execute a query / statement.
    16→    |
    17→    */
    18→
    19→    'default' => env('DB_CONNECTION', 'sqlite'),
    20→
    21→    /*
    22→    |--------------------------------------------------------------------------
    23→    | Database Connections
    24→    |--------------------------------------------------------------------------
    25→    |
    26→    | Below are all of the database connections defined for your application.
    27→    | An example configuration is provided for each database system which
    28→    | is supported by Laravel. You're free to add / remove connections.
    29→    |
    30→    */
    31→
    32→    'connections' => [
    33→
    34→        'sqlite' => [
    35→            'driver' => 'sqlite',
    36→            'url' => env('DB_URL'),
    37→            'database' => env('DB_DATABASE', database_path('database.sqlite')),
    38→            'prefix' => '',
    39→            'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
    40→            'busy_timeout' => null,
    41→            'journal_mode' => null,
    42→            'synchronous' => null,
    43→            'transaction_mode' => 'DEFERRED',
    44→        ],
    45→
    46→        'mysql' => [
    47→            'driver' => 'mysql',
    48→            'url' => env('DB_URL'),
    49→            'host' => env('DB_HOST', '127.0.0.1'),
    50→            'port' => env('DB_PORT', '3306'),
    51→            'database' => env('DB_DATABASE', 'laravel'),
    52→            'username' => env('DB_USERNAME', 'root'),
    53→            'password' => env('DB_PASSWORD', ''),
    54→            'unix_socket' => env('DB_SOCKET', ''),
    55→            'charset' => env('DB_CHARSET', 'utf8mb4'),
    56→            'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
    57→            'prefix' => '',
    58→            'prefix_indexes' => true,
    59→            'strict' => true,
    60→            'engine' => null,
    61→            'options' => extension_loaded('pdo_mysql') ? array_filter([
    62→                (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
    63→            ]) : [],
    64→        ],
    65→
    66→        'mariadb' => [
    67→            'driver' => 'mariadb',
    68→            'url' => env('DB_URL'),
    69→            'host' => env('DB_HOST', '127.0.0.1'),
    70→            'port' => env('DB_PORT', '3306'),
    71→            'database' => env('DB_DATABASE', 'laravel'),
    72→            'username' => env('DB_USERNAME', 'root'),
    73→            'password' => env('DB_PASSWORD', ''),
    74→            'unix_socket' => env('DB_SOCKET', ''),
    75→            'charset' => env('DB_CHARSET', 'utf8mb4'),
    76→            'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
    77→            'prefix' => '',
    78→            'prefix_indexes' => true,
    79→            'strict' => true,
    80→            'engine' => null,
    81→            'options' => extension_loaded('pdo_mysql') ? array_filter([
    82→                (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
    83→            ]) : [],
    84→        ],
    85→
    86→        'pgsql' => [
    87→            'driver' => 'pgsql',
    88→            'url' => env('DB_URL'),
    89→            'host' => env('DB_HOST', '127.0.0.1'),
    90→            'port' => env('DB_PORT', '5432'),
    91→            'database' => env('DB_DATABASE', 'laravel'),
    92→            'username' => env('DB_USERNAME', 'root'),
    93→            'password' => env('DB_PASSWORD', ''),
    94→            'charset' => env('DB_CHARSET', 'utf8'),
    95→            'prefix' => '',
    96→            'prefix_indexes' => true,
    97→            'search_path' => 'public',
    98→            'sslmode' => env('DB_SSLMODE', 'prefer'),
    99→        ],
   100→
   101→        'sqlsrv' => [
   102→            'driver' => 'sqlsrv',
   103→            'url' => env('DB_URL'),
   104→            'host' => env('DB_HOST', 'localhost'),
   105→            'port' => env('DB_PORT', '1433'),
   106→            'database' => env('DB_DATABASE', 'laravel'),
   107→            'username' => env('DB_USERNAME', 'root'),
   108→            'password' => env('DB_PASSWORD', ''),
   109→            'charset' => env('DB_CHARSET', 'utf8'),
   110→            'prefix' => '',
   111→            'prefix_indexes' => true,
   112→            // 'encrypt' => env('DB_ENCRYPT', 'yes'),
   113→            // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
   114→        ],
   115→
   116→    ],
   117→
   118→    /*
   119→    |--------------------------------------------------------------------------
   120→    | Migration Repository Table
   121→    |--------------------------------------------------------------------------
   122→    |
   123→    | This table keeps track of all the migrations that have already run for
   124→    | your application. Using this information, we can determine which of
   125→    | the migrations on disk haven't actually been run on the database.
   126→    |
   127→    */
   128→
   129→    'migrations' => [
   130→        'table' => 'migrations',
   131→        'update_date_on_publish' => true,
   132→    ],
   133→
   134→    /*
   135→    |--------------------------------------------------------------------------
   136→    | Redis Databases
   137→    |--------------------------------------------------------------------------
   138→    |
   139→    | Redis is an open source, fast, and advanced key-value store that also
   140→    | provides a richer body of commands than a typical key-value system
   141→    | such as Memcached. You may define your connection settings here.
   142→    |
   143→    */
   144→
   145→    'redis' => [
   146→
   147→        'client' => env('REDIS_CLIENT', 'phpredis'),
   148→
   149→        'options' => [
   150→            'cluster' => env('REDIS_CLUSTER', 'redis'),
   151→            'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
   152→            'persistent' => env('REDIS_PERSISTENT', false),
   153→        ],
   154→
   155→        'default' => [
   156→            'url' => env('REDIS_URL'),
   157→            'host' => env('REDIS_HOST', '127.0.0.1'),
   158→            'username' => env('REDIS_USERNAME'),
   159→            'password' => env('REDIS_PASSWORD'),
   160→            'port' => env('REDIS_PORT', '6379'),
   161→            'database' => env('REDIS_DB', '0'),
   162→            'max_retries' => env('REDIS_MAX_RETRIES', 3),
   163→            'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
   164→            'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
   165→            'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
   166→        ],
   167→
   168→        'cache' => [
   169→            'url' => env('REDIS_URL'),
   170→            'host' => env('REDIS_HOST', '127.0.0.1'),
   171→            'username' => env('REDIS_USERNAME'),
   172→            'password' => env('REDIS_PASSWORD'),
   173→            'port' => env('REDIS_PORT', '6379'),
   174→            'database' => env('REDIS_CACHE_DB', '1'),
   175→            'max_retries' => env('REDIS_MAX_RETRIES', 3),
   176→            'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
   177→            'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
   178→            'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
   179→        ],
   180→
   181→    ],
   182→
   183→];
   184→

<?php

use Illuminate\Support\Str;

return [

    /*
    |--------------------------------------------------------------------------
    | Default Database Connection Name
    |--------------------------------------------------------------------------
    |
    | Here you may specify which of the database connections below you wish
    | to use as your default connection for database operations. This is
    | the connection which will be utilized unless another connection
    | is explicitly specified when you execute a query / statement.
    |
    */

    'default' => env('DB_CONNECTION', 'sqlite'),

    /*
    |--------------------------------------------------------------------------
    | Database Connections
    |--------------------------------------------------------------------------
    |
    | Below are all of the database connections defined for your application.
    | An example configuration is provided for each database system which
    | is supported by Laravel. You're free to add / remove connections.
    |
    */

    'connections' => [

        'sqlite' => [
            'driver' => 'sqlite',
            'url' => env('DB_URL'),
            'database' => env('DB_DATABASE', database_path('database.sqlite')),
            'prefix' => '',
            'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
            'busy_timeout' => null,
            'journal_mode' => null,
            'synchronous' => null,
            'transaction_mode' => 'DEFERRED',
        ],

        'mysql' => [
            'driver' => 'mysql',
            'url' => env('DB_URL'),
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '3306'),
            'database' => env('DB_DATABASE', 'laravel'),
            'username' => env('DB_USERNAME', 'root'),
            'password' => env('DB_PASSWORD', ''),
            'unix_socket' => env('DB_SOCKET', ''),
            'charset' => env('DB_CHARSET', 'utf8mb4'),
            'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
            'prefix' => '',
            'prefix_indexes' => true,
            'strict' => true,
            'engine' => null,
            'options' => extension_loaded('pdo_mysql') ? array_filter([
                (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
            ]) : [],
        ],

        'mariadb' => [
            'driver' => 'mariadb',
            'url' => env('DB_URL'),
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '3306'),
            'database' => env('DB_DATABASE', 'laravel'),
            'username' => env('DB_USERNAME', 'root'),
            'password' => env('DB_PASSWORD', ''),
            'unix_socket' => env('DB_SOCKET', ''),
            'charset' => env('DB_CHARSET', 'utf8mb4'),
            'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
            'prefix' => '',
            'prefix_indexes' => true,
            'strict' => true,
            'engine' => null,
            'options' => extension_loaded('pdo_mysql') ? array_filter([
                (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
            ]) : [],
        ],

        'pgsql' => [
            'driver' => 'pgsql',
            'url' => env('DB_URL'),
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '5432'),
            'database' => env('DB_DATABASE', 'laravel'),
            'username' => env('DB_USERNAME', 'root'),
            'password' => env('DB_PASSWORD', ''),
            'charset' => env('DB_CHARSET', 'utf8'),
            'prefix' => '',
            'prefix_indexes' => true,
            'search_path' => 'public',
            'sslmode' => env('DB_SSLMODE', 'prefer'),
        ],

        'sqlsrv' => [
            'driver' => 'sqlsrv',
            'url' => env('DB_URL'),
            'host' => env('DB_HOST', 'localhost'),
            'port' => env('DB_PORT', '1433'),
            'database' => env('DB_DATABASE', 'laravel'),
            'username' => env('DB_USERNAME', 'root'),
            'password' => env('DB_PASSWORD', ''),
            'charset' => env('DB_CHARSET', 'utf8'),
            'prefix' => '',
            'prefix_indexes' => true,
            // 'encrypt' => env('DB_ENCRYPT', 'yes'),
            // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
        ],

    ],

    /*
    |--------------------------------------------------------------------------
    | Migration Repository Table
    |--------------------------------------------------------------------------
    |
    | This table keeps track of all the migrations that have already run for
    | your application. Using this information, we can determine which of
    | the migrations on disk haven't actually been run on the database.
    |
    */

    'migrations' => [
        'table' => 'migrations',
        'update_date_on_publish' => true,
    ],

    /*
    |--------------------------------------------------------------------------
    | Redis Databases
    |--------------------------------------------------------------------------
    |
    | Redis is an open source, fast, and advanced key-value store that also
    | provides a richer body of commands than a typical key-value system
    | such as Memcached. You may define your connection settings here.
    |
    */

    'redis' => [

        'client' => env('REDIS_CLIENT', 'phpredis'),

        'options' => [
            'cluster' => env('REDIS_CLUSTER', 'redis'),
            'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
            'persistent' => env('REDIS_PERSISTENT', false),
        ],

        'default' => [
            'url' => env('REDIS_URL'),
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'username' => env('REDIS_USERNAME'),
            'password' => env('REDIS_PASSWORD'),
            'port' => env('REDIS_PORT', '6379'),
            'database' => env('REDIS_DB', '0'),
            'max_retries' => env('REDIS_MAX_RETRIES', 3),
            'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
            'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
            'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
        ],

        'cache' => [
            'url' => env('REDIS_URL'),
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'username' => env('REDIS_USERNAME'),
            'password' => env('REDIS_PASSWORD'),
            'port' => env('REDIS_PORT', '6379'),
            'database' => env('REDIS_CACHE_DB', '1'),
            'max_retries' => env('REDIS_MAX_RETRIES', 3),
            'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
            'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
            'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
        ],

    ],

];


I now have a thorough understanding of the current state. Let me start the implementation. This is an enormous project - I need to be strategic and use sub-agents effectively.

The approach I'll take:
1. **Phase 1**: Foundation - I'll do this directly since everything depends on it and I need precision
2. **Phases 2-5**: Core business logic - Use sub-agents in parallel where possible
3. **Phases 6-10**: Features - Sub-agents
4. **Phases 11-12**: Polish and testing

Let me start by implementing Phase 1 step by step. I'll create the migrations, models, enums, middleware, traits, and auth system.

I'll begin with the foundation pieces, delegating parallel work to sub-agents where it makes sense within this phase.


Now I have full context. Let me start implementing Phase 1 - Foundation. I'll use sub-agents to parallelize the work.


Task

Phase 1: Foundation setup

Prompt:
You are implementing Phase 1 (Foundation) of an e-commerce platform in a Laravel 12 + Livewire v4 + SQLite project at /Users/fabianwesner/Herd/shop.

IMPORTANT: Never use the em-dash character in texts. Use hyphens or commas instead.

The current app is a fresh Laravel 12 install with Fortify already configured. SQLite is already the DB. You need to implement ALL of the following:

## Step 1.1: Config Updates

Update `config/database.php` SQLite connection:
- busy_timeout: 5000
- journal_mode: 'wal'
- synchronous: 'normal'

Update `config/auth.php`:
- Add `customer` guard with session driver, `customers` provider
- Add `customers` provider: driver=eloquent, model=App\Models\Customer::class
- Add `customers` password broker

## Step 1.2: Core Migrations

Create migrations in this dependency order (use timestamps like 2025_01_01_000010, etc for proper ordering):

1. `create_organizations_table`:
   - id, name (text not null), billing_email (text not null), timestamps
   - Index on billing_email

2. `create_stores_table`:
   - id, organization_id (FK->organizations CASCADE), name (text not null), handle (text not null unique), status (text not null default 'active', CHECK IN active/suspended), default_currency (text not null default 'USD'), default_locale (text not null default 'en'), timezone (text not null default 'UTC'), timestamps
   - Indexes: unique handle, organization_id, status

3. `create_store_domains_table`:
   - id, store_id (FK->stores CASCADE), hostname (text not null unique), type (text not null default 'storefront', CHECK IN storefront/admin/api), is_primary (integer not null default 0), tls_mode (text not null default 'managed', CHECK IN managed/bring_your_own), created_at
   - Indexes: unique hostname, store_id, composite store_id+is_primary

4. Modify the users table (create a new migration that adds columns):
   - Add status (text not null default 'active', CHECK IN active/disabled)
   - Add last_login_at (text nullable)
   - Note: two_factor columns already exist from Fortify migration

5. `create_store_users_table`:
   - Composite PK: store_id + user_id (both FK with CASCADE)
   - role (text not null default 'staff', CHECK IN owner/admin/staff/support)
   - created_at (text nullable)
   - Indexes: user_id, composite store_id+role

6. `create_store_settings_table`:
   - PK: store_id (FK->stores CASCADE)
   - settings_json (text not null default '{}')
   - updated_at (text nullable)

## Step 1.3: Enums

Create in `app/Enums/`:
- `StoreStatus`: Active, Suspended (backed by string: 'active', 'suspended')
- `StoreUserRole`: Owner, Admin, Staff, Support (backed by string: 'owner', 'admin', 'staff', 'support')
- `StoreDomainType`: Storefront, Admin, Api (backed by string: 'storefront', 'admin', 'api')

## Step 1.4: Core Models

Create these models with factories. Each model must have explicit return types, proper $fillable, casts() method.

**Organization**: hasMany(Store)
- fillable: name, billing_email
- Factory: meaningful defaults

**Store**: belongsTo(Organization), hasMany(StoreDomain), belongsToMany(User)->using(StoreUser)->withPivot('role')->withTimestamps(), hasOne(StoreSettings)
- fillable: organization_id, name, handle, status, default_currency, default_locale, timezone
- casts: status -> StoreStatus
- Factory: meaningful defaults

**StoreDomain**: belongsTo(Store)
- fillable: store_id, hostname, type, is_primary, tls_mode
- casts: type -> StoreDomainType, is_primary -> boolean
- Factory

**StoreUser** (extends Pivot, not Model):
- protected $table = 'store_users'
- public $incrementing = false
- casts: role -> StoreUserRole

**StoreSettings**: belongsTo(Store)
- primaryKey = 'store_id', incrementing = false
- fillable: store_id, settings_json
- casts: settings_json -> array

**Update User model**:
- Add status, last_login_at to fillable
- Add casts for status, last_login_at -> datetime
- Add belongsToMany(Store)->using(StoreUser)->withPivot('role')->withTimestamps()
- Add method `roleForStore(Store $store): ?StoreUserRole` that checks the pivot

## Step 1.5: BelongsToStore Trait and StoreScope

Create `app/Models/Scopes/StoreScope.php`:
- Applies `where('store_id', app('current_store')->id)` when `current_store` is bound

Create `app/Models/Concerns/BelongsToStore.php`:
- Boots the trait: adds StoreScope global scope
- On 'creating' event: auto-sets store_id from app('current_store') if not already set
- Defines belongsTo(Store) relationship

## Step 1.6: Tenant Resolution Middleware

Create `app/Http/Middleware/ResolveStore.php`:
- For storefront routes: extract hostname, look up store_domains, cache for 5 min, bind Store as singleton 'current_store', set in session
- For admin routes: read current_store_id from session, verify user has store_users record
- Return 404 for unknown hostname
- Return 503 for suspended stores on storefront
- The middleware needs to detect if it's an admin or storefront route (check if URL starts with /admin)

Register in bootstrap/app.php:
- Add middleware aliases: 'store.resolve' => ResolveStore::class
- Update routing to include api routes file

Also create `app/Http/Middleware/CheckRole.php`:
- Accepts roles as parameters
- Checks user's role for current store against allowed roles
- Register alias: 'role.check'

## Step 1.7: Authentication Setup

### Admin Auth (Livewire components):

Create `app/Livewire/Admin/Auth/Login.php`:
- Properties: email, password, remember
- Method login(): validates, attempts auth with web guard, rate limited (5/min per IP)
- On success: set current_store_id in session (first store user is associated with), redirect to /admin
- On failure: add error
- View: admin.auth.login

Create `app/Livewire/Admin/Auth/Logout.php` (or just an action):
- Logs out, invalidates session, redirects to /admin/login

### Customer Auth:

Create `app/Auth/CustomerUserProvider.php`:
- Extends EloquentUserProvider
- Overrides retrieveByCredentials to scope by current store_id
- Registered in AppServiceProvider

Create `app/Livewire/Storefront/Account/Auth/Login.php`:
- Similar to admin login but uses customer guard
- Rate limited

Create `app/Livewire/Storefront/Account/Auth/Register.php`:
- Creates customer scoped to current store
- Email unique per store validation
- Auto-login after registration

Register rate limiters in AppServiceProvider::boot() for login (5 per minute per IP).
Register CustomerUserProvider in AppServiceProvider::register().

## Step 1.8: Authorization (Policies)

Create policies for: Product, Order, Collection, Discount, Customer, Store, Page, Theme, Fulfillment, Refund

Each policy checks user's role via store_users pivot for the current store. Follow the permission matrix:

| Permission | Owner | Admin | Staff | Support |
|Manage store settings | Y | Y | N | N |
|Delete store | Y | N | N | N |
|Manage staff | Y | Y | N | N |
|Manage products (CRUD) | Y | Y | Y | N |
|Delete/archive products | Y | Y | N | N |
|Manage orders | Y | Y | Y | Read-only |
|Process refunds | Y | Y | N | N |
|Create fulfillments | Y | Y | Y | N |
|Manage discounts | Y | Y | Y | N |
|Manage themes | Y | Y | N | N |
|View analytics | Y | Y | Y | N |
|Manage shipping/tax | Y | Y | N | N |
|View customers | Y | Y | Y | Y |
|Update customers | Y | Y | Y | N |

## Routes Setup

Create `routes/admin.php`:
```php
// Admin auth routes (no auth required)
Route::get('/admin/login', Login::class)->name('admin.login');
Route::get('/admin/forgot-password', ForgotPassword::class)->name('admin.forgot-password');
Route::get('/admin/reset-password/{token}', ResetPassword::class)->name('admin.reset-password');

// Admin protected routes
Route::prefix('admin')->middleware(['auth', 'store.resolve'])->group(function () {
    Route::get('/', Dashboard::class)->name('admin.dashboard');
    // More routes added in later phases
});
```

Create `routes/storefront.php`:
```php
// Public storefront routes
Route::middleware(['store.resolve'])->group(function () {
    Route::get('/', Home::class)->name('storefront.home');
    // More routes added in later phases

    // Customer auth routes
    Route::get('/account/login', CustomerLogin::class)->name('customer.login');
    Route::get('/account/register', CustomerRegister::class)->name('customer.register');

    // Customer protected routes
    Route::middleware(['auth:customer'])->prefix('account')->group(function () {
        Route::get('/', AccountDashboard::class)->name('customer.dashboard');
    });
});
```

Update routes/web.php to include both route files.
Update bootstrap/app.php to register middleware and API routes.

## Blade Views

Create minimal Blade views for the auth components:
- resources/views/livewire/admin/auth/login.blade.php
- resources/views/livewire/storefront/account/auth/login.blade.php
- resources/views/livewire/storefront/account/auth/register.blade.php

Use Flux UI components where applicable (flux:input, flux:button, etc.)

## IMPORTANT RULES:
- Use PHP 8.4 features, constructor property promotion
- Always use explicit return type declarations
- Use casts() method not $casts property
- Enums keys should be TitleCase
- Use curly braces for all control structures
- Run `php artisan migrate:fresh` at the end to verify migrations work
- Run `vendor/bin/pint --dirty` to fix code style
- DO NOT create test files yet - those come later
- Make sure all factories have meaningful defaults
- Create a DatabaseSeeder that seeds: 1 organization, 1 store, 1 store domain (hostname: shop.test), 1 admin user with owner role, store settings


Async agent launched successfully.
agentId: a50a3ae (internal ID - do not mention to user. Use to resume later if needed.)
The agent is working in the background. You will be notified automatically when it completes.
Continue with other tasks.
output_file: /private/tmp/claude-501/-Users-fabianwesner-Herd-shop/tasks/a50a3ae.output
To check progress before completion (optional), use Read or Bash tail on the output file.

Phase 1: Foundation setup

Prompt:
You are implementing Phase 1 (Foundation) of an e-commerce platform in a Laravel 12 + Livewire v4 + SQLite project at /Users/fabianwesner/Herd/shop.

IMPORTANT: Never use the em-dash character in texts. Use hyphens or commas instead.

The current app is a fresh Laravel 12 install with Fortify already configured. SQLite is already the DB. You need to implement ALL of the following:

## Step 1.1: Config Updates

Update `config/database.php` SQLite connection:
- busy_timeout: 5000
- journal_mode: 'wal'
- synchronous: 'normal'

Update `config/auth.php`:
- Add `customer` guard with session driver, `customers` provider
- Add `customers` provider: driver=eloquent, model=App\Models\Customer::class
- Add `customers` password broker

## Step 1.2: Core Migrations

Create migrations in this dependency order (use timestamps like 2025_01_01_000010, etc for proper ordering):

1. `create_organizations_table`:
   - id, name (text not null), billing_email (text not null), timestamps
   - Index on billing_email

2. `create_stores_table`:
   - id, organization_id (FK->organizations CASCADE), name (text not null), handle (text not null unique), status (text not null default 'active', CHECK IN active/suspended), default_currency (text not null default 'USD'), default_locale (text not null default 'en'), timezone (text not null default 'UTC'), timestamps
   - Indexes: unique handle, organization_id, status

3. `create_store_domains_table`:
   - id, store_id (FK->stores CASCADE), hostname (text not null unique), type (text not null default 'storefront', CHECK IN storefront/admin/api), is_primary (integer not null default 0), tls_mode (text not null default 'managed', CHECK IN managed/bring_your_own), created_at
   - Indexes: unique hostname, store_id, composite store_id+is_primary

4. Modify the users table (create a new migration that adds columns):
   - Add status (text not null default 'active', CHECK IN active/disabled)
   - Add last_login_at (text nullable)
   - Note: two_factor columns already exist from Fortify migration

5. `create_store_users_table`:
   - Composite PK: store_id + user_id (both FK with CASCADE)
   - role (text not null default 'staff', CHECK IN owner/admin/staff/support)
   - created_at (text nullable)
   - Indexes: user_id, composite store_id+role

6. `create_store_settings_table`:
   - PK: store_id (FK->stores CASCADE)
   - settings_json (text not null default '{}')
   - updated_at (text nullable)

## Step 1.3: Enums

Create in `app/Enums/`:
- `StoreStatus`: Active, Suspended (backed by string: 'active', 'suspended')
- `StoreUserRole`: Owner, Admin, Staff, Support (backed by string: 'owner', 'admin', 'staff', 'support')
- `StoreDomainType`: Storefront, Admin, Api (backed by string: 'storefront', 'admin', 'api')

## Step 1.4: Core Models

Create these models with factories. Each model must have explicit return types, proper $fillable, casts() method.

**Organization**: hasMany(Store)
- fillable: name, billing_email
- Factory: meaningful defaults

**Store**: belongsTo(Organization), hasMany(StoreDomain), belongsToMany(User)->using(StoreUser)->withPivot('role')->withTimestamps(), hasOne(StoreSettings)
- fillable: organization_id, name, handle, status, default_currency, default_locale, timezone
- casts: status -> StoreStatus
- Factory: meaningful defaults

**StoreDomain**: belongsTo(Store)
- fillable: store_id, hostname, type, is_primary, tls_mode
- casts: type -> StoreDomainType, is_primary -> boolean
- Factory

**StoreUser** (extends Pivot, not Model):
- protected $table = 'store_users'
- public $incrementing = false
- casts: role -> StoreUserRole

**StoreSettings**: belongsTo(Store)
- primaryKey = 'store_id', incrementing = false
- fillable: store_id, settings_json
- casts: settings_json -> array

**Update User model**:
- Add status, last_login_at to fillable
- Add casts for status, last_login_at -> datetime
- Add belongsToMany(Store)->using(StoreUser)->withPivot('role')->withTimestamps()
- Add method `roleForStore(Store $store): ?StoreUserRole` that checks the pivot

## Step 1.5: BelongsToStore Trait and StoreScope

Create `app/Models/Scopes/StoreScope.php`:
- Applies `where('store_id', app('current_store')->id)` when `current_store` is bound

Create `app/Models/Concerns/BelongsToStore.php`:
- Boots the trait: adds StoreScope global scope
- On 'creating' event: auto-sets store_id from app('current_store') if not already set
- Defines belongsTo(Store) relationship

## Step 1.6: Tenant Resolution Middleware

Create `app/Http/Middleware/ResolveStore.php`:
- For storefront routes: extract hostname, look up store_domains, cache for 5 min, bind Store as singleton 'current_store', set in session
- For admin routes: read current_store_id from session, verify user has store_users record
- Return 404 for unknown hostname
- Return 503 for suspended stores on storefront
- The middleware needs to detect if it's an admin or storefront route (check if URL starts with /admin)

Register in bootstrap/app.php:
- Add middleware aliases: 'store.resolve' => ResolveStore::class
- Update routing to include api routes file

Also create `app/Http/Middleware/CheckRole.php`:
- Accepts roles as parameters
- Checks user's role for current store against allowed roles
- Register alias: 'role.check'

## Step 1.7: Authentication Setup

### Admin Auth (Livewire components):

Create `app/Livewire/Admin/Auth/Login.php`:
- Properties: email, password, remember
- Method login(): validates, attempts auth with web guard, rate limited (5/min per IP)
- On success: set current_store_id in session (first store user is associated with), redirect to /admin
- On failure: add error
- View: admin.auth.login

Create `app/Livewire/Admin/Auth/Logout.php` (or just an action):
- Logs out, invalidates session, redirects to /admin/login

### Customer Auth:

Create `app/Auth/CustomerUserProvider.php`:
- Extends EloquentUserProvider
- Overrides retrieveByCredentials to scope by current store_id
- Registered in AppServiceProvider

Create `app/Livewire/Storefront/Account/Auth/Login.php`:
- Similar to admin login but uses customer guard
- Rate limited

Create `app/Livewire/Storefront/Account/Auth/Register.php`:
- Creates customer scoped to current store
- Email unique per store validation
- Auto-login after registration

Register rate limiters in AppServiceProvider::boot() for login (5 per minute per IP).
Register CustomerUserProvider in AppServiceProvider::register().

## Step 1.8: Authorization (Policies)

Create policies for: Product, Order, Collection, Discount, Customer, Store, Page, Theme, Fulfillment, Refund

Each policy checks user's role via store_users pivot for the current store. Follow the permission matrix:

| Permission | Owner | Admin | Staff | Support |
|Manage store settings | Y | Y | N | N |
|Delete store | Y | N | N | N |
|Manage staff | Y | Y | N | N |
|Manage products (CRUD) | Y | Y | Y | N |
|Delete/archive products | Y | Y | N | N |
|Manage orders | Y | Y | Y | Read-only |
|Process refunds | Y | Y | N | N |
|Create fulfillments | Y | Y | Y | N |
|Manage discounts | Y | Y | Y | N |
|Manage themes | Y | Y | N | N |
|View analytics | Y | Y | Y | N |
|Manage shipping/tax | Y | Y | N | N |
|View customers | Y | Y | Y | Y |
|Update customers | Y | Y | Y | N |

## Routes Setup

Create `routes/admin.php`:
```php
// Admin auth routes (no auth required)
Route::get('/admin/login', Login::class)->name('admin.login');
Route::get('/admin/forgot-password', ForgotPassword::class)->name('admin.forgot-password');
Route::get('/admin/reset-password/{token}', ResetPassword::class)->name('admin.reset-password');

// Admin protected routes
Route::prefix('admin')->middleware(['auth', 'store.resolve'])->group(function () {
    Route::get('/', Dashboard::class)->name('admin.dashboard');
    // More routes added in later phases
});
```

Create `routes/storefront.php`:
```php
// Public storefront routes
Route::middleware(['store.resolve'])->group(function () {
    Route::get('/', Home::class)->name('storefront.home');
    // More routes added in later phases

    // Customer auth routes
    Route::get('/account/login', CustomerLogin::class)->name('customer.login');
    Route::get('/account/register', CustomerRegister::class)->name('customer.register');

    // Customer protected routes
    Route::middleware(['auth:customer'])->prefix('account')->group(function () {
        Route::get('/', AccountDashboard::class)->name('customer.dashboard');
    });
});
```

Update routes/web.php to include both route files.
Update bootstrap/app.php to register middleware and API routes.

## Blade Views

Create minimal Blade views for the auth components:
- resources/views/livewire/admin/auth/login.blade.php
- resources/views/livewire/storefront/account/auth/login.blade.php
- resources/views/livewire/storefront/account/auth/register.blade.php

Use Flux UI components where applicable (flux:input, flux:button, etc.)

## IMPORTANT RULES:
- Use PHP 8.4 features, constructor property promotion
- Always use explicit return type declarations
- Use casts() method not $casts property
- Enums keys should be TitleCase
- Use curly braces for all control structures
- Run `php artisan migrate:fresh` at the end to verify migrations work
- Run `vendor/bin/pint --dirty` to fix code style
- DO NOT create test files yet - those come later
- Make sure all factories have meaningful defaults
- Create a DatabaseSeeder that seeds: 1 organization, 1 store, 1 store domain (hostname: shop.test), 1 admin user with owner role, store settings


Phase 1 agent is running in the background. While it works, let me prepare by reading the remaining spec content I need for Phase 2.


   300→
   301→| Column | Type | Nullable | Default | Constraints | Notes |
   302→|---|---|---|---|---|---|
   303→| id | INTEGER | No | autoincrement | PRIMARY KEY | |
   304→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
   305→| title | TEXT | No | - | - | Collection display title |
   306→| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
   307→| description_html | TEXT | Yes | NULL | - | Rich-text description |
   308→| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |
   309→| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |
   310→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   311→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   312→
   313→**Indexes:**
   314→
   315→- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
   316→- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store
   317→- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status
   318→
   319→---
   320→
   321→### collection_products
   322→
   323→Pivot linking products to collections with ordering. Composite primary key.
   324→
   325→| Column | Type | Nullable | Default | Constraints | Notes |
   326→|---|---|---|---|---|---|
   327→| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |
   328→| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |
   329→| position | INTEGER | No | 0 | - | Display ordering within collection |
   330→
   331→**Primary Key:** (`collection_id`, `product_id`)
   332→
   333→**Indexes:**
   334→
   335→- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product
   336→- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection
   337→
   338→---
   339→
   340→### product_media
   341→
   342→Images and videos attached to a product.
   343→
   344→| Column | Type | Nullable | Default | Constraints | Notes |
   345→|---|---|---|---|---|---|
   346→| id | INTEGER | No | autoincrement | PRIMARY KEY | |
   347→| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
   348→| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |
   349→| storage_key | TEXT | No | - | - | File storage path/key |
   350→| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |
   351→| width | INTEGER | Yes | NULL | - | Pixel width |
   352→| height | INTEGER | Yes | NULL | - | Pixel height |
   353→| mime_type | TEXT | Yes | NULL | - | e.g. "image/jpeg" |
   354→| byte_size | INTEGER | Yes | NULL | - | File size in bytes |
   355→| position | INTEGER | No | 0 | - | Display ordering |
   356→| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |
   357→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   358→
   359→**Indexes:**
   360→
   361→- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product
   362→- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing
   363→- `idx_product_media_status` on (`status`) - Finding media by processing status
   364→
   365→---
   366→
   367→## Epic 3 - Themes, Storefront, and Localization
   368→
   369→### themes
   370→
   371→Storefront theme definitions per store.
   372→
   373→| Column | Type | Nullable | Default | Constraints | Notes |
   374→|---|---|---|---|---|---|
   375→| id | INTEGER | No | autoincrement | PRIMARY KEY | |
   376→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
   377→| name | TEXT | No | - | - | Theme display name |
   378→| version | TEXT | Yes | NULL | - | Semantic version string |
   379→| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |
   380→| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   381→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   382→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   383→
   384→**Indexes:**
   385→
   386→- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store
   387→- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store
   388→
   389→---
   390→
   391→### theme_files
   392→
   393→Individual files belonging to a theme (templates, assets, snippets).
   394→
   395→| Column | Type | Nullable | Default | Constraints | Notes |
   396→|---|---|---|---|---|---|
   397→| id | INTEGER | No | autoincrement | PRIMARY KEY | |
   398→| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |
   399→| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |
   400→| storage_key | TEXT | No | - | - | File storage path/key |
   401→| sha256 | TEXT | No | - | - | Content hash for cache busting |
   402→| byte_size | INTEGER | No | 0 | - | File size in bytes |
   403→
   404→**Indexes:**
   405→
   406→- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme
   407→- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme
   408→
   409→---
   410→
   411→### theme_settings
   412→
   413→Single-row JSON configuration per theme.
   414→
   415→| Column | Type | Nullable | Default | Constraints | Notes |
   416→|---|---|---|---|---|---|
   417→| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |
   418→| settings_json | TEXT | No | '{}' | - | JSON settings object |
   419→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   420→
   421→---
   422→
   423→### pages
   424→
   425→Static CMS pages (About Us, Contact, etc.).
   426→
   427→| Column | Type | Nullable | Default | Constraints | Notes |
   428→|---|---|---|---|---|---|
   429→| id | INTEGER | No | autoincrement | PRIMARY KEY | |
   430→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
   431→| title | TEXT | No | - | - | Page title |
   432→| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
   433→| body_html | TEXT | Yes | NULL | - | Rich-text content |
   434→| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |
   435→| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   436→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   437→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   438→
   439→**Indexes:**
   440→
   441→- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
   442→- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store
   443→- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status
   444→
   445→---
   446→
   447→### navigation_menus
   448→
   449→Named menus (e.g. main-menu, footer-menu).
   450→
   451→| Column | Type | Nullable | Default | Constraints | Notes |
   452→|---|---|---|---|---|---|
   453→| id | INTEGER | No | autoincrement | PRIMARY KEY | |
   454→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
   455→| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. "main-menu" |
   456→| title | TEXT | No | - | - | Display title |
   457→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   458→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   459→
   460→**Indexes:**
   461→
   462→- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store
   463→- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store
   464→
   465→---
   466→
   467→### navigation_items
   468→
   469→Individual links within a navigation menu (supports nesting via position).
   470→
   471→| Column | Type | Nullable | Default | Constraints | Notes |
   472→|---|---|---|---|---|---|
   473→| id | INTEGER | No | autoincrement | PRIMARY KEY | |
   474→| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |
   475→| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |
   476→| label | TEXT | No | - | - | Display label |
   477→| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |
   478→| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |
   479→| position | INTEGER | No | 0 | - | Display ordering |
   480→
   481→**Indexes:**
   482→
   483→- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu
   484→- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing
   485→
   486→**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.
   487→
   488→---
   489→
   490→## Epic 4 - Search
   491→
   492→### search_settings
   493→
   494→Per-store search configuration (synonyms, stop words).
   495→
   496→| Column | Type | Nullable | Default | Constraints | Notes |
   497→|---|---|---|---|---|---|
   498→| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
   499→| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |
   500→| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |
   501→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   502→
   503→---
   504→
   505→### search_queries
   506→
   507→Search query log for analytics and autocomplete.
   508→
   509→| Column | Type | Nullable | Default | Constraints | Notes |
   510→|---|---|---|---|---|---|
   511→| id | INTEGER | No | autoincrement | PRIMARY KEY | |
   512→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
   513→| query | TEXT | No | - | - | Search query text |
   514→| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |
   515→| results_count | INTEGER | No | 0 | - | Number of results returned |
   516→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   517→
   518→**Indexes:**
   519→
   520→- `idx_search_queries_store_id` on (`store_id`) - FK lookup
   521→- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store
   522→- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups
   523→
   524→---
   525→
   526→## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes
   527→
   528→### carts
   529→
   530→Shopping cart, optionally linked to a customer.
   531→
   532→| Column | Type | Nullable | Default | Constraints | Notes |
   533→|---|---|---|---|---|---|
   534→| id | INTEGER | No | autoincrement | PRIMARY KEY | |
   535→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
   536→| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |
   537→| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
   538→| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |
   539→| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |
   540→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   541→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   542→
   543→**Indexes:**
   544→
   545→- `idx_carts_store_id` on (`store_id`) - FK lookup
   546→- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer
   547→- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)
   548→
   549→**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.
   550→
   551→---
   552→
   553→### cart_lines
   554→
   555→Individual line items within a cart.
   556→
   557→| Column | Type | Nullable | Default | Constraints | Notes |
   558→|---|---|---|---|---|---|
   559→| id | INTEGER | No | autoincrement | PRIMARY KEY | |
   560→| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |
   561→| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |
   562→| quantity | INTEGER | No | 1 | - | Number of units |
   563→| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |
   564→| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |
   565→| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |
   566→| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |
   567→
   568→**Indexes:**
   569→
   570→- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart
   571→- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart
   572→
   573→**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`.
   574→
   575→---
   576→
   577→### checkouts
   578→
   579→Checkout session progressing through address, shipping, payment steps.
   580→
   581→| Column | Type | Nullable | Default | Constraints | Notes |
   582→|---|---|---|---|---|---|
   583→| id | INTEGER | No | autoincrement | PRIMARY KEY | |
   584→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
   585→| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |
   586→| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |
   587→| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |
   588→| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |
   589→| email | TEXT | Yes | NULL | - | Contact email |
   590→| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |
   591→| billing_address_json | TEXT | Yes | NULL | - | JSON address object |
   592→| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |
   593→| discount_code | TEXT | Yes | NULL | - | Applied discount code |
   594→| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |
   595→| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |
   596→| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |
   597→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   598→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
   599→

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


     1→# 05 - Business Logic Specification
     2→
     3→> E-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12
     4→
     5→---
     6→
     7→## Conventions
     8→
     9→| Convention | Detail |
    10→|---|---|
    11→| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |
    12→| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |
    13→| Rounding | Round each line independently, then sum (prevents rounding drift) |
    14→| Database | SQLite - single-writer, WAL mode, no advisory locks needed |
    15→| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |
    16→| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |
    17→
    18→---
    19→
    20→## 1. Multi-Tenancy and Store Isolation
    21→
    22→### 1.1 Tenant Resolution Middleware
    23→
    24→**Class:** `App\Http\Middleware\ResolveStore`
    25→
    26→**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.
    27→
    28→#### Storefront Route Resolution (from request hostname)
    29→
    30→1. Extract hostname from the incoming HTTP request.
    31→2. Look up `store_domains` table where `hostname` matches.
    32→3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.
    33→4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.
    34→5. If hostname is not found in `store_domains`: return HTTP 404.
    35→6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.
    36→
    37→#### Admin Route Resolution (from session)
    38→
    39→1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).
    40→2. Verify the authenticated user has a `store_users` record for that store.
    41→3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.
    42→
    43→```mermaid
    44→flowchart TD
    45→    A[Incoming Request] --> B{Route type?}
    46→    B -->|Storefront| C[Extract hostname]
    47→    C --> D{Hostname in cache?}
    48→    D -->|Yes| E[Load store from cache]
    49→    D -->|No| F[Query store_domains table]
    50→    F --> G{Found?}
    51→    G -->|No| H[Return 404]
    52→    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]
    53→    I --> E
    54→    E --> J{Store suspended?}
    55→    J -->|Yes| K[Return 503 maintenance]
    56→    J -->|No| L[Bind store as singleton, continue]
    57→
    58→    B -->|Admin| M[Read store_id from session]
    59→    M --> N{User has store_users record?}
    60→    N -->|No| O[Return 403]
    61→    N -->|Yes| P{Store suspended?}
    62→    P -->|Yes| Q[Return 403 for mutations]
    63→    P -->|No| L
    64→```
    65→
    66→### 1.2 Store Scoping
    67→
    68→**Trait:** `App\Models\Concerns\BelongsToStore`
    69→**Global Scope:** `App\Models\Scopes\StoreScope`
    70→
    71→The trait applies a global query scope to every model that uses it. The scope adds a `WHERE store_id = <current_store_id>` clause to all queries automatically.
    72→
    73→**Behavior:**
    74→
    75→- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).
    76→- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.
    77→- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.
    78→
    79→**Models that must use this trait:**
    80→products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
    81→
    82→### 1.3 Role-Based Access Control
    83→
    84→**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`
    85→
    86→**Permission Matrix:**
    87→
    88→| Permission | Owner | Admin | Staff | Support |
    89→|---|---|---|---|---|
    90→| Manage store settings | Yes | Yes | No | No |
    91→| Manage staff | Yes | Yes | No | No |
    92→| Manage products | Yes | Yes | Yes | No |
    93→| Manage orders | Yes | Yes | Yes | Yes (read-only) |
    94→| Manage discounts | Yes | Yes | Yes | No |
    95→| Manage themes | Yes | Yes | No | No |
    96→| Manage apps | Yes | Yes | No | No |
    97→| View analytics | Yes | Yes | Yes | No |
    98→| Manage shipping/tax | Yes | Yes | No | No |
    99→| Process refunds | Yes | Yes | No | No |
   100→| Create fulfillments | Yes | Yes | Yes | No |
   101→
   102→**Implementation requirements:**
   103→
   104→- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).
   105→- Register gate definitions in a service provider.
   106→- Each policy method receives the User, looks up the user's role for the current store via the `store_users` pivot table, and checks the matrix above.
   107→- Add a helper method on the User model - `roleForStore(store)` - that returns the user's role for a given store or null if the user has no access.
   108→
   109→---
   110→
   111→## 2. Product Lifecycle
   112→
   113→### 2.1 Status Transitions
   114→
   115→**Enum:** `App\Enums\ProductStatus` - values: `draft`, `active`, `archived`
   116→
   117→```mermaid
   118→stateDiagram-v2
   119→    [*] --> Draft : Product created
   120→    Draft --> Active : Publish (requires variant with price > 0, title not empty)
   121→    Draft --> Archived : Archive
   122→    Active --> Archived : Archive (hide from storefront)
   123→    Archived --> Active : Re-publish (same preconditions as Draft->Active)
   124→    Active --> Draft : Revert (BLOCKED if order_lines exist)
   125→    Archived --> Draft : Revert (BLOCKED if order_lines exist)
   126→```
   127→
   128→**Transition rules:**
   129→
   130→| From | To | Preconditions |
   131→|---|---|---|
   132→| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |
   133→| Draft | Archived | None |
   134→| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |
   135→| Archived | Active | Same preconditions as Draft to Active |
   136→| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |
   137→| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |
   138→
   139→**Deletion rules:**
   140→- Only `draft` products with zero `order_lines` references may be hard-deleted.
   141→- Products with existing order references must be archived, never deleted, to preserve order history integrity.
   142→
   143→**Service:** `App\Services\ProductService`
   144→- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.
   145→- On activation: set `published_at` to current time if not already set.
   146→- Dispatch `ProductStatusChanged` event after successful transition.
   147→
   148→### 2.2 Variant and Option Matrix
   149→
   150→**Rules:**
   151→
   152→- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.
   153→- Each option has 1 or more values, stored in `product_option_values`.
   154→- Variants represent the cartesian product of option values (or a manually curated subset).
   155→- When a product has no options, a single "Default" variant is auto-created with `is_default = 1`.
   156→
   157→**Matrix rebuild logic (when options change):**
   158→
   159→```
   160→PROCEDURE rebuildMatrix(product):
   161→    current_option_values = load all option values grouped by option
   162→    desired_combos = cartesianProduct(current_option_values)
   163→    existing_variants = load all variants with their option value sets
   164→
   165→    FOR EACH combo IN desired_combos:
   166→        IF combo matches an existing variant's option value set:
   167→            SKIP (preserve existing variant - prices, SKUs, inventory intact)
   168→        ELSE:
   169→            CREATE new variant with default pricing from first existing variant
   170→
   171→    FOR EACH existing_variant NOT matching any desired combo:
   172→        IF variant has order_line references:
   173→            ARCHIVE variant (set status = 'archived')
   174→        ELSE:
   175→            DELETE variant
   176→```
   177→
   178→**Service:** `App\Services\VariantMatrixService`
   179→
   180→### 2.3 SKU Uniqueness
   181→
   182→- Combination of store_id (derived through the product's store) and SKU must be unique.
   183→- Enforced at application level before save.
   184→- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).
   185→- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.
   186→
   187→### 2.4 Handle/Slug Generation
   188→
   189→**Helper class:** `App\Support\HandleGenerator`
   190→
   191→- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).
   192→- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.
   193→- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.
   194→- Merchants can manually edit the handle; on save, re-validate uniqueness.
   195→- Same logic applies to collections and pages.
   196→
   197→---
   198→
   199→## 3. Inventory Management
   200→
   201→### 3.1 Tracking Model
   202→
   203→Each `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).
   204→
   205→| Field | Description |
   206→|---|---|
   207→| `quantity_on_hand` | Total physical stock count |
   208→| `quantity_reserved` | Units held for active checkouts or pending orders |
   209→| **Available** (computed) | `quantity_on_hand - quantity_reserved` |
   210→
   211→### 3.2 Inventory Policy
   212→
   213→Per-variant policy stored in `inventory_items.policy`:
   214→
   215→| Policy | Behavior |
   216→|---|---|
   217→| `deny` | Block add-to-cart when available <= 0. Storefront displays "Out of stock". |
   218→| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays "Available on backorder". |
   219→
   220→### 3.3 Inventory Operations
   221→
   222→**Service:** `App\Services\InventoryService`
   223→
   224→All operations must execute within a database transaction.
   225→
   226→```mermaid
   227→flowchart LR
   228→    subgraph "Stock Lifecycle"
   229→        A[On Hand] -->|Reserve| B[Reserved]
   230→        B -->|Commit on payment| C[Deducted]
   231→        B -->|Release on expiry| A
   232→        C -->|Restock on refund| A
   233→    end
   234→```
   235→
   236→**Operation definitions:**
   237→
   238→| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |
   239→|---|---|---|---|---|
   240→| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
   241→| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
   242→| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
   243→| **Restock** | Refund processed with restock flag | +quantity | No change | None |
   244→
   245→### 3.4 Concurrency
   246→
   247→SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.
   248→
   249→---
   250→
   251→## 4. Cart Service
   252→
   253→### 4.1 Cart Lifecycle
   254→
   255→**Service:** `App\Services\CartService`
   256→
   257→```mermaid
   258→stateDiagram-v2
   259→    [*] --> active : Cart created
   260→    active --> converted : Checkout completed successfully
   261→    active --> abandoned : Cleanup job (14 days inactive)
   262→```
   263→
   264→**Cart identification:**
   265→- Guest users: cart ID stored in session cookie.
   266→- Authenticated customers: cart linked via `customer_id` foreign key.
   267→
   268→The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.
   269→
   270→**Cart merge on login:**
   271→
   272→```
   273→PROCEDURE mergeGuestCart(guestCart, customerCart):
   274→    FOR EACH line IN guestCart.lines:
   275→        existingLine = customerCart.findLineByVariant(line.variant_id)
   276→        IF existingLine EXISTS:
   277→            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
   278→            RECALCULATE existingLine amounts
   279→        ELSE:
   280→            MOVE line to customerCart
   281→    MARK guestCart as abandoned
   282→    RECALCULATE all line amounts on customerCart
   283→```
   284→
   285→### 4.2 Cart Operations
   286→
   287→**Create cart:**
   288→- Accepts: store, optional customer
   289→- Sets: store_id, currency from store's default_currency, initial cart_version = 1
   290→
   291→**Add line - validations (in order):**
   292→
   293→1. Variant exists and belongs to the current store (through its product).
   294→2. Product has status = `active`.
   295→3. Variant has status = `active`.
   296→4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.
   297→5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.
   298→6. Set `unit_price_amount` from the variant's `price_amount`.
   299→7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.
   300→8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).

# 05 - Business Logic Specification

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

---

## Conventions

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

---

## 1. Multi-Tenancy and Store Isolation

### 1.1 Tenant Resolution Middleware

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

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

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

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

#### Admin Route Resolution (from session)

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

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

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

### 1.2 Store Scoping

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

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

**Behavior:**

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

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

### 1.3 Role-Based Access Control

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

**Permission Matrix:**

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

**Implementation requirements:**

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

---

## 2. Product Lifecycle

### 2.1 Status Transitions

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

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

**Transition rules:**

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

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

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

### 2.2 Variant and Option Matrix

**Rules:**

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

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

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

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

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

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

### 2.3 SKU Uniqueness

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

### 2.4 Handle/Slug Generation

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

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

---

## 3. Inventory Management

### 3.1 Tracking Model

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

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

### 3.2 Inventory Policy

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

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

### 3.3 Inventory Operations

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

All operations must execute within a database transaction.

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

**Operation definitions:**

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

### 3.4 Concurrency

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

---

## 4. Cart Service

### 4.1 Cart Lifecycle

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

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

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

The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.

**Cart merge on login:**

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

### 4.2 Cart Operations

**Create cart:**
- Accepts: store, optional customer
- Sets: store_id, currency from store's default_currency, initial cart_version = 1

**Add line - validations (in order):**

1. Variant exists and belongs to the current store (through its product).
2. Product has status = `active`.
3. Variant has status = `active`.
4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.
5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.
6. Set `unit_price_amount` from the variant's `price_amount`.
7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.
8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).


Good, Phase 1 agent is still running. Let me continue reading the remaining specs while waiting. Let me read more of the database schema and business logic.


Let me continue reading the remaining database schema for the later phases while the Phase 1 agent works.