type: user

role: user

content:
<retrieval_status>not_ready</retrieval_status>

<task_id>a3952e1</task_id>

<task_type>local_agent</task_type>

<status>running</status>

<output>
[Truncated. Full output: /private/tmp/claude-501/-Users-fabianwesner-Herd-shop/tasks/a3952e1.output]

lling_address_json',\n    30→        'shipping_method_id',\n    31→        'discount_code',\n    32→        'tax_provider_snapshot_json',\n    33→        'totals_json',\n    34→        'expires_at',\n    35→    ];\n    36→\n    37→    /**\n    38→     * @return array<string, string>\n    39→     */\n    40→    protected function casts(): array\n    41→    {\n    42→        return [\n    43→            'status' => CheckoutStatus::class,\n    44→            'shipping_address_json' => 'array',\n    45→            'billing_address_json' => 'array',\n    46→            'tax_provider_snapshot_json' => 'array',\n    47→            'totals_json' => 'array',\n    48→            'expires_at' => 'datetime',\n    49→        ];\n    50→    }\n    51→\n    52→    /**\n    53→     * @return BelongsTo<Cart, $this>\n    54→     */\n    55→    public function cart(): BelongsTo\n    56→    {\n    57→        return $this->belongsTo(Cart::class);\n    58→    }\n    59→\n    60→    /**\n    61→     * @return BelongsTo<Customer, $this>\n    62→     */\n    63→    public function customer(): BelongsTo\n    64→    {\n    65→        return $this->belongsTo(Customer::class);\n    66→    }\n    67→}\n    68→"}]},"uuid":"ac41d71a-9bd7-44f0-b272-0cc6ed4a8f9f","timestamp":"2026-02-13T15:24:02.402Z","sourceToolAssistantUUID":"e9aa7d09-faf6-4e43-8116-5663f4b404eb"}
{"parentUuid":"ac41d71a-9bd7-44f0-b272-0cc6ed4a8f9f","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013XQxoK5xrzJdKjCGGWW1Te","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/app/Models/Cart.php"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"9c8ea3c5-c190-4e70-9a2d-7cdc870e4fda","timestamp":"2026-02-13T15:24:02.829Z"}
{"parentUuid":"9c8ea3c5-c190-4e70-9a2d-7cdc870e4fda","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_013XQxoK5xrzJdKjCGGWW1Te","toolUseID":"toolu_013XQxoK5xrzJdKjCGGWW1Te","timestamp":"2026-02-13T15:24:02.836Z","uuid":"7fbbf78f-ddc4-4cfe-aea2-72352aae6ca6"}
{"parentUuid":"9c8ea3c5-c190-4e70-9a2d-7cdc870e4fda","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013XQxoK5xrzJdKjCGGWW1Te","type":"tool_result","content":"     1→<?php\n     2→\n     3→namespace App\\Models;\n     4→\n     5→use App\\Enums\\CartStatus;\n     6→use App\\Models\\Concerns\\BelongsToStore;\n     7→use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\n     8→use Illuminate\\Database\\Eloquent\\Model;\n     9→use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\n    10→use Illuminate\\Database\\Eloquent\\Relations\\HasMany;\n    11→\n    12→class Cart extends Model\n    13→{\n    14→    /** @use HasFactory<\\Database\\Factories\\CartFactory> */\n    15→    use BelongsToStore, HasFactory;\n    16→\n    17→    /**\n    18→     * The attributes that are mass assignable.\n    19→     *\n    20→     * @var list<string>\n    21→     */\n    22→    protected $fillable = [\n    23→        'store_id',\n    24→        'customer_id',\n    25→        'currency',\n    26→        'cart_version',\n    27→        'status',\n    28→    ];\n    29→\n    30→    /**\n    31→     * @return array<string, string>\n    32→     */\n    33→    protected function casts(): array\n    34→    {\n    35→        return [\n    36→            'status' => CartStatus::class,\n    37→            'cart_version' => 'integer',\n    38→        ];\n    39→    }\n    40→\n    41→    /**\n    42→     * @return BelongsTo<Customer, $this>\n    43→     */\n    44→    public function customer(): BelongsTo\n    45→    {\n    46→        return $this->belongsTo(Customer::class);\n    47→    }\n    48→\n    49→    /**\n    50→     * @return HasMany<CartLine, $this>\n    51→     */\n    52→    public function lines(): HasMany\n    53→    {\n    54→        return $this->hasMany(CartLine::class);\n    55→    }\n    56→\n    57→    /**\n    58→     * @return HasMany<Checkout, $this>\n    59→     */\n    60→    public function checkouts(): HasMany\n    61→    {\n    62→        return $this->hasMany(Checkout::class);\n    63→    }\n    64→}\n    65→"}]},"uuid":"6a8d03cf-9a9f-4b09-ae4d-3081ce0a925b","timestamp":"2026-02-13T15:24:02.835Z","sourceToolAssistantUUID":"9c8ea3c5-c190-4e70-9a2d-7cdc870e4fda"}
{"parentUuid":"6a8d03cf-9a9f-4b09-ae4d-3081ce0a925b","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0182nUFcmVxDAxrCCBiFQqBM","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/app/Models/Customer.php"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"9bd8b18e-bc7f-4070-bfab-a72736b86132","timestamp":"2026-02-13T15:24:03.278Z"}
{"parentUuid":"9bd8b18e-bc7f-4070-bfab-a72736b86132","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_0182nUFcmVxDAxrCCBiFQqBM","toolUseID":"toolu_0182nUFcmVxDAxrCCBiFQqBM","timestamp":"2026-02-13T15:24:03.285Z","uuid":"9959f2d3-c967-4543-8857-374fa490c1d8"}
{"parentUuid":"9bd8b18e-bc7f-4070-bfab-a72736b86132","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0182nUFcmVxDAxrCCBiFQqBM","type":"tool_result","content":"     1→<?php\n     2→\n     3→namespace App\\Models;\n     4→\n     5→use App\\Models\\Concerns\\BelongsToStore;\n     6→use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\n     7→use Illuminate\\Foundation\\Auth\\User as Authenticatable;\n     8→use Illuminate\\Notifications\\Notifiable;\n     9→\n    10→class Customer extends Authenticatable\n    11→{\n    12→    /** @use HasFactory<\\Database\\Factories\\CustomerFactory> */\n    13→    use BelongsToStore, HasFactory, Notifiable;\n    14→\n    15→    /**\n    16→     * The attributes that are mass assignable.\n    17→     *\n    18→     * @var list<string>\n    19→     */\n    20→    protected $fillable = [\n    21→        'store_id',\n    22→        'email',\n    23→        'password',\n    24→        'name',\n    25→        'marketing_opt_in',\n    26→    ];\n    27→\n    28→    /**\n    29→     * The attributes that should be hidden for serialization.\n    30→     *\n    31→     * @var list<string>\n    32→     */\n    33→    protected $hidden = [\n    34→        'password',\n    35→        'remember_token',\n    36→    ];\n    37→\n    38→    /**\n    39→     * @return array<string, string>\n    40→     */\n    41→    protected function casts(): array\n    42→    {\n    43→        return [\n    44→            'email_verified_at' => 'datetime',\n    45→            'password' => 'hashed',\n    46→            'marketing_opt_in' => 'boolean',\n    47→        ];\n    48→    }\n    49→}\n    50→"}]},"uuid":"b238cee5-25c9-4341-b250-4e7eb974b3d8","timestamp":"2026-02-13T15:24:03.284Z","sourceToolAssistantUUID":"9bd8b18e-bc7f-4070-bfab-a72736b86132"}
{"parentUuid":"b238cee5-25c9-4341-b250-4e7eb974b3d8","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015kmaSJch8qY5HBnJWJL9Dq","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/app/Models/Product.php"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"3e3cb013-516d-4404-b511-df1099c87b31","timestamp":"2026-02-13T15:24:04.134Z"}
{"parentUuid":"3e3cb013-516d-4404-b511-df1099c87b31","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_015kmaSJch8qY5HBnJWJL9Dq","toolUseID":"toolu_015kmaSJch8qY5HBnJWJL9Dq","timestamp":"2026-02-13T15:24:04.153Z","uuid":"b4b01322-cabe-4299-bc51-23cac268fd59"}
{"parentUuid":"3e3cb013-516d-4404-b511-df1099c87b31","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015kmaSJch8qY5HBnJWJL9Dq","type":"tool_result","content":"     1→<?php\n     2→\n     3→namespace App\\Models;\n     4→\n     5→use App\\Enums\\ProductStatus;\n     6→use App\\Models\\Concerns\\BelongsToStore;\n     7→use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\n     8→use Illuminate\\Database\\Eloquent\\Model;\n     9→use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;\n    10→use Illuminate\\Database\\Eloquent\\Relations\\HasMany;\n    11→\n    12→class Product extends Model\n    13→{\n    14→    /** @use HasFactory<\\Database\\Factories\\ProductFactory> */\n    15→    use BelongsToStore, HasFactory;\n    16→\n    17→    /**\n    18→     * The attributes that are mass assignable.\n    19→     *\n    20→     * @var list<string>\n    21→     */\n    22→    protected $fillable = [\n    23→        'store_id',\n    24→        'title',\n    25→        'handle',\n    26→        'status',\n    27→        'description_html',\n    28→        'vendor',\n    29→        'product_type',\n    30→        'tags',\n    31→        'published_at',\n    32→    ];\n    33→\n    34→    /**\n    35→     * @return array<string, string>\n    36→     */\n    37→    protected function casts(): array\n    38→    {\n    39→        return [\n    40→            'status' => ProductStatus::class,\n    41→            'tags' => 'array',\n    42→            'published_at' => 'datetime',\n    43→        ];\n    44→    }\n    45→\n    46→    /**\n    47→     * @return HasMany<ProductVariant, $this>\n    48→     */\n    49→    public function variants(): HasMany\n    50→    {\n    51→        return $this->hasMany(ProductVariant::class);\n    52→    }\n    53→\n    54→    /**\n    55→     * @return HasMany<ProductOption, $this>\n    56→     */\n    57→    public function options(): HasMany\n    58→    {\n    59→        return $this->hasMany(ProductOption::class);\n    60→    }\n    61→\n    62→    /**\n    63→     * @return HasMany<ProductMedia, $this>\n    64→     */\n    65→    public function media(): HasMany\n    66→    {\n    67→        return $this->hasMany(ProductMedia::class);\n    68→    }\n    69→\n    70→    /**\n    71→     * @return BelongsToMany<Collection, $this>\n    72→     */\n    73→    public function collections(): BelongsToMany\n    74→    {\n    75→        return $this->belongsToMany(Collection::class, 'collection_products')\n    76→            ->withPivot('position');\n    77→    }\n    78→}\n    79→"}]},"uuid":"a509cd61-27ad-433b-a0c9-58d120f01593","timestamp":"2026-02-13T15:24:04.153Z","sourceToolAssistantUUID":"3e3cb013-516d-4404-b511-df1099c87b31"}
{"parentUuid":"a509cd61-27ad-433b-a0c9-58d120f01593","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013eWNS9aotMzjLnp1RWyrDk","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/app/Services/InventoryService.php"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"bf58f5e3-47d5-43a8-8574-ef94e887efcf","timestamp":"2026-02-13T15:24:04.307Z"}
{"parentUuid":"bf58f5e3-47d5-43a8-8574-ef94e887efcf","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_013eWNS9aotMzjLnp1RWyrDk","toolUseID":"toolu_013eWNS9aotMzjLnp1RWyrDk","timestamp":"2026-02-13T15:24:04.331Z","uuid":"8dcec609-ea91-4e39-a8e2-d837c9dd873f"}
{"parentUuid":"bf58f5e3-47d5-43a8-8574-ef94e887efcf","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013eWNS9aotMzjLnp1RWyrDk","type":"tool_result","content":"     1→<?php\n     2→\n     3→namespace App\\Services;\n     4→\n     5→use App\\Enums\\InventoryPolicy;\n     6→use App\\Exceptions\\InsufficientInventoryException;\n     7→use App\\Models\\InventoryItem;\n     8→use Illuminate\\Support\\Facades\\DB;\n     9→\n    10→class InventoryService\n    11→{\n    12→    /**\n    13→     * Check if sufficient available inventory exists for the given quantity.\n    14→     */\n    15→    public function checkAvailability(InventoryItem $item, int $quantity): bool\n    16→    {\n    17→        if ($item->policy === InventoryPolicy::Continue) {\n    18→            return true;\n    19→        }\n    20→\n    21→        return $item->availableQuantity() >= $quantity;\n    22→    }\n    23→\n    24→    /**\n    25→     * Reserve inventory for a checkout or pending order.\n    26→     * Increments quantity_reserved without changing quantity_on_hand.\n    27→     *\n    28→     * @throws InsufficientInventoryException\n    29→     */\n    30→    public function reserve(InventoryItem $item, int $quantity): void\n    31→    {\n    32→        DB::transaction(function () use ($item, $quantity) {\n    33→            $item->refresh();\n    34→\n    35→            if ($item->policy === InventoryPolicy::Deny && $item->availableQuantity() < $quantity) {\n    36→                throw new InsufficientInventoryException(\n    37→                    $item->variant_id,\n    38→                    $quantity,\n    39→                    $item->availableQuantity(),\n    40→                );\n    41→            }\n    42→\n    43→            $item->increment('quantity_reserved', $quantity);\n    44→        });\n    45→    }\n    46→\n    47→    /**\n    48→     * Release previously reserved inventory (e.g., checkout expired or abandoned).\n    49→     * Decrements quantity_reserved without changing quantity_on_hand.\n    50→     */\n    51→    public function release(InventoryItem $item, int $quantity): void\n    52→    {\n    53→        DB::transaction(function () use ($item, $quantity) {\n    54→            $item->refresh();\n    55→            $item->decrement('quantity_reserved', $quantity);\n    56→        });\n    57→    }\n    58→\n    59→    /**\n    60→     * Commit inventory when payment is confirmed.\n    61→     * Decrements both quantity_on_hand and quantity_reserved.\n    62→     */\n    63→    public function commit(InventoryItem $item, int $quantity): void\n    64→    {\n    65→        DB::transaction(function () use ($item, $quantity) {\n    66→            $item->refresh();\n    67→            $item->decrement('quantity_on_hand', $quantity);\n    68→            $item->decrement('quantity_reserved', $quantity);\n    69→        });\n    70→    }\n    71→\n    72→    /**\n    73→     * Restock inventory (e.g., after a refund with restock flag).\n    74→     * Increments quantity_on_hand.\n    75→     */\n    76→    public function restock(InventoryItem $item, int $quantity): void\n    77→    {\n    78→        DB::transaction(function () use ($item, $quantity) {\n    79→            $item->refresh();\n    80→            $item->increment('quantity_on_hand', $quantity);\n    81→        });\n    82→    }\n    83→}\n    84→"}]},"uuid":"19af28ba-f5eb-4962-a67a-dbf979a60feb","timestamp":"2026-02-13T15:24:04.330Z","sourceToolAssistantUUID":"bf58f5e3-47d5-43a8-8574-ef94e887efcf"}
{"parentUuid":"19af28ba-f5eb-4962-a67a-dbf979a60feb","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UAMFQ4ZTtENxJMMysEDs8S","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/app/Services/CheckoutService.php"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"d1b2061a-e8f7-4b7b-804b-0ef433cdebcf","timestamp":"2026-02-13T15:24:04.790Z"}
{"parentUuid":"d1b2061a-e8f7-4b7b-804b-0ef433cdebcf","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01UAMFQ4ZTtENxJMMysEDs8S","toolUseID":"toolu_01UAMFQ4ZTtENxJMMysEDs8S","timestamp":"2026-02-13T15:24:04.810Z","uuid":"bd136a9b-990c-462c-97fa-6eab31394404"}
{"parentUuid":"d1b2061a-e8f7-4b7b-804b-0ef433cdebcf","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UAMFQ4ZTtENxJMMysEDs8S","type":"tool_result","content":"     1→<?php\n     2→\n     3→namespace App\\Services;\n     4→\n     5→use App\\Enums\\CartStatus;\n     6→use App\\Enums\\CheckoutStatus;\n     7→use App\\Exceptions\\InvalidCheckoutTransitionException;\n     8→use App\\Models\\Cart;\n     9→use App\\Models\\Checkout;\n    10→use App\\Models\\ShippingRate;\n    11→use Illuminate\\Support\\Facades\\DB;\n    12→use InvalidArgumentException;\n    13→\n    14→class CheckoutService\n    15→{\n    16→    /**\n    17→     * Valid state transitions for the checkout state machine.\n    18→     *\n    19→     * @var array<string, list<string>>\n    20→     */\n    21→    private const TRANSITIONS = [\n    22→        'started' => ['addressed', 'expired'],\n    23→        'addressed' => ['shipping_selected', 'expired'],\n    24→        'shipping_selected' => ['payment_selected', 'expired'],\n    25→        'payment_selected' => ['completed', 'expired'],\n    26→    ];\n    27→\n    28→    public function __construct(\n    29→        private PricingEngine $pricingEngine,\n    30→        private ShippingCalculator $shippingCalculator,\n    31→    ) {}\n    32→\n    33→    /**\n    34→     * Create a new checkout from a cart.\n    35→     */\n    36→    public function createCheckout(Cart $cart): Checkout\n    37→    {\n    38→        if ($cart->lines()->count() === 0) {\n    39→            throw new InvalidArgumentException('Cannot create checkout for empty cart');\n    40→        }\n    41→\n    42→        return Checkout::withoutGlobalScopes()->create([\n    43→            'store_id' => $cart->store_id,\n    44→            'cart_id' => $cart->id,\n    45→            'customer_id' => $cart->customer_id,\n    46→            'status' => CheckoutStatus::Started,\n    47→        ]);\n    48→    }\n    49→\n    50→    /**\n    51→     * Set the address on a checkout.\n    52→     *\n    53→     * @param  array{email: string, shipping_address: array<string, mixed>, billing_address?: array<string, mixed>}  $data\n    54→     */\n    55→    public function setAddress(Checkout $checkout, array $data): Checkout\n    56→    {\n    57→        $this->validateTransition($checkout, CheckoutStatus::Addressed);\n    58→\n    59→        return DB::transaction(function () use ($checkout, $data) {\n    60→            $shippingAddress = $data['shipping_address'];\n    61→            $billingAddress = $data['billing_address'] ?? $shippingAddress;\n    62→\n    63→            $checkout->update([\n    64→                'email' => $data['email'],\n    65→                'shipping_address_json' => $shippingAddress,\n    66→                'billing_address_json' => $billingAddress,\n    67→                'status' => CheckoutStatus::Addressed,\n    68→            ]);\n    69→\n    70→            $this->recalculate($checkout);\n    71→\n    72→            return $checkout->refresh();\n    73→        });\n    74→    }\n    75→\n    76→    /**\n    77→     * Select a shipping method on a checkout.\n    78→     */\n    79→    public function setShippingMethod(Checkout $checkout, int $shippingRateId): Checkout\n    80→    {\n    81→        $this->validateTransition($checkout, CheckoutStatus::ShippingSelected);\n    82→\n    83→        return DB::transaction(function () use ($checkout, $shippingRateId) {\n    84→            $rate = ShippingRate::findOrFail($shippingRateId);\n    85→\n    86→            $checkout->update([\n    87→                'shipping_method_id' => $rate->id,\n    88→                'status' => CheckoutStatus::ShippingSelected,\n    89→            ]);\n    90→\n    91→            $this->recalculate($checkout);\n    92→\n    93→            return $checkout->refresh();\n    94→        });\n    95→    }\n    96→\n    97→    /**\n    98→     * Skip shipping selection when no items require shipping.\n    99→     */\n   100→    public function skipShipping(Checkout $checkout): Checkout\n   101→    {\n   102→        $this->validateTransition($checkout, CheckoutStatus::ShippingSelected);\n   103→\n   104→        return DB::transaction(function () use ($checkout) {\n   105→            $checkout->update([\n   106→                'shipping_method_id' => null,\n   107→                'status' => CheckoutStatus::ShippingSelected,\n   108→            ]);\n   109→\n   110→            $this->recalculate($checkout);\n   111→\n   112→            return $checkout->refresh();\n   113→        });\n   114→    }\n   115→\n   116→    /**\n   117→     * Select a payment method on a checkout.\n   118→     */\n   119→    public function selectPaymentMethod(Checkout $checkout, string $paymentMethod): Checkout\n   120→    {\n   121→        $this->validateTransition($checkout, CheckoutStatus::PaymentSelected);\n   122→\n   123→        $validMethods = ['credit_card', 'paypal', 'bank_transfer'];\n   124→\n   125→        if (! in_array($paymentMethod, $validMethods)) {\n   126→            throw new InvalidArgumentException(\"Invalid payment method: {$paymentMethod}\");\n   127→        }\n   128→\n   129→        return DB::transaction(function () use ($checkout, $paymentMethod) {\n   130→            $checkout->update([\n   131→                'payment_method' => $paymentMethod,\n   132→                'status' => CheckoutStatus::PaymentSelected,\n   133→                'expires_at' => now()->addHours(24),\n   134→            ]);\n   135→\n   136→            $this->recalculate($checkout);\n   137→\n   138→            return $checkout->refresh();\n   139→        });\n   140→    }\n   141→\n   142→    /**\n   143→     * Apply a discount code to a checkout.\n   144→     */\n   145→    public function applyDiscountCode(Checkout $checkout, string $code): Checkout\n   146→    {\n   147→        return DB::transaction(function () use ($checkout, $code) {\n   148→            $checkout->update(['discount_code' => $code]);\n   149→            $this->recalculate($checkout);\n   150→\n   151→            return $checkout->refresh();\n   152→        });\n   153→    }\n   154→\n   155→    /**\n   156→     * Remove discount from a checkout.\n   157→     */\n   158→    public function removeDiscount(Checkout $checkout): Checkout\n   159→    {\n   160→        return DB::transaction(function () use ($checkout) {\n   161→            $checkout->update(['discount_code' => null]);\n   162→            $this->recalculate($checkout);\n   163→\n   164→            return $checkout->refresh();\n   165→        });\n   166→    }\n   167→\n   168→    /**\n   169→     * Transition the checkout to completed state.\n   170→     */\n   171→    public function completeCheckout(Checkout $checkout): Checkout\n   172→    {\n   173→        $this->validateTransition($checkout, CheckoutStatus::Completed);\n   174→\n   175→        return DB::transaction(function () use ($checkout) {\n   176→            $cart = Cart::withoutGlobalScopes()->findOrFail($checkout->cart_id);\n   177→            $cart->update(['status' => CartStatus::Converted]);\n   178→\n   179→            $checkout->update([\n   180→                'status' => CheckoutStatus::Completed,\n   181→            ]);\n   182→\n   183→            return $checkout->refresh();\n   184→        });\n   185→    }\n   186→\n   187→    /**\n   188→     * Expire a checkout.\n   189→     */\n   190→    public function expireCheckout(Checkout $checkout): Checkout\n   191→    {\n   192→        if ($checkout->status === CheckoutStatus::Completed || $checkout->status === CheckoutStatus::Expired) {\n   193→            throw new InvalidCheckoutTransitionException($checkout->status, CheckoutStatus::Expired);\n   194→        }\n   195→\n   196→        $checkout->update([\n   197→            'status' => CheckoutStatus::Expired,\n   198→        ]);\n   199→\n   200→        return $checkout->refresh();\n   201→    }\n   202→\n   203→    /**\n   204→     * Validate that a state transition is allowed.\n   205→     *\n   206→     * @throws InvalidCheckoutTransitionException\n   207→     */\n   208→    private function validateTransition(Checkout $checkout, CheckoutStatus $target): void\n   209→    {\n   210→        $current = $checkout->status->value;\n   211→        $allowed = self::TRANSITIONS[$current] ?? [];\n   212→\n   213→        if (! in_array($target->value, $allowed)) {\n   214→            throw new InvalidCheckoutTransitionException($checkout->status, $target);\n   215→        }\n   216→    }\n   217→\n   218→    /**\n   219→     * Recalculate pricing and store snapshot.\n   220→     */\n   221→    private function recalculate(Checkout $checkout): void\n   222→    {\n   223→        $result = $this->pricingEngine->calculate($checkout);\n   224→\n   225→        $checkout->update([\n   226→            'totals_json' => $result->toArray(),\n   227→        ]);\n   228→    }\n   229→}\n   230→"}]},"uuid":"eee5ac32-5fd6-4776-9f94-dbf25f7b1669","timestamp":"2026-02-13T15:24:04.809Z","sourceToolAssistantUUID":"d1b2061a-e8f7-4b7b-804b-0ef433cdebcf"}
</output>

status: running

description: Phase 5: Payments, Orders, Fulfillment

output:
{"parentUuid":null,"isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":"You are implementing Phase 5 (Payments, Orders, Fulfillment) of an e-commerce shop in Laravel 12 with Livewire v4, Flux UI Free v2, SQLite, and Pest v4.\n\n## Context\n- Phases 1-4 complete. Working directory: /Users/fabianwesner/Herd/shop\n- 208 existing tests pass. DO NOT modify existing tests.\n- Read specs before implementing: `specs/01-DATABASE-SCHEMA.md`, `specs/05-BUSINESS-LOGIC.md`, `specs/09-IMPLEMENTATION-ROADMAP.md`\n- Read existing models/services/factories to understand patterns before coding.\n\n## What to implement\n\n### Step 5.1: Migrations (numbered 000400-000470)\nCreate tables per specs/01-DATABASE-SCHEMA.md:\n- `orders` - store_id, customer_id (nullable), order_number (unique per store), payment_method, status, financial_status, fulfillment_status, currency, amounts (subtotal, discount, shipping, tax, total), email, addresses JSON, placed_at\n- `order_lines` - order_id, product_id (nullable SET NULL), variant_id (nullable SET NULL), title_snapshot, sku_snapshot, quantity, unit_price_amount, total_amount, tax_lines_json, discount_allocations_json\n- `payments` - order_id, provider ('mock'), method, provider_payment_id, status, amount, currency, raw_json_encrypted\n- `refunds` - order_id, payment_id, amount, reason, status, provider_refund_id\n- `fulfillments` - order_id, status, tracking_company, tracking_number, tracking_url, shipped_at\n- `fulfillment_lines` - fulfillment_id, order_line_id, quantity (unique on fulfillment_id + order_line_id)\n\n### Step 5.2: Models\nCreate with BelongsToStore where applicable:\n- Order (belongsTo Store, Customer; hasMany OrderLine, Payment, Refund, Fulfillment)\n- OrderLine (belongsTo Order, Product nullable, ProductVariant nullable)\n- Payment (belongsTo Order)\n- Refund (belongsTo Order, Payment)\n- Fulfillment (belongsTo Order; hasMany FulfillmentLine)\n- FulfillmentLine (belongsTo Fulfillment, OrderLine)\n\nEnums: OrderStatus, FinancialStatus, FulfillmentStatus, PaymentMethod, PaymentStatus, RefundStatus, FulfillmentShipmentStatus\n\n### Step 5.3: Payment Service (Mock PSP)\n- Interface: `App\\Contracts\\PaymentProvider` with charge() and refund()\n- Implementation: `App\\Services\\Payments\\MockPaymentProvider`\n- Magic card numbers: 4242424242424242 (success), 4000000000000002 (decline), 4000000000009995 (insufficient funds)\n- Credit card/PayPal: instant capture (status=captured)\n- Bank transfer: deferred capture (status=pending)\n- Generates mock reference IDs (mock_ + random)\n- Register in AppServiceProvider\n\n### Step 5.3b: Admin Payment Confirmation\n- Job: CancelUnpaidBankTransferOrders (daily via routes/console.php)\n- Admin \"Confirm Payment\" action in OrderService\n\n### Step 5.4: Order Service (`App\\Services\\OrderService`)\n- createFromCheckout(Checkout): atomic - create order + lines with snapshots, commit inventory, mark cart converted\n- generateOrderNumber(Store): sequential per store (#1001, #1002, etc.)\n- cancel(Order, reason): only if not fulfilled, releases inventory\n\n### Step 5.5: Refund Service (`App\\Services\\RefundService`)\n- create(Order, Payment, amount, reason, restock): validates amount, calls provider refund, updates financial_status\n- Partial refunds -> partially_refunded, full refunds -> refunded\n- Restock flag restocks inventory\n\n### Step 5.6: Fulfillment Service (`App\\Services\\FulfillmentService`)\n- create(Order, lines, tracking): creates fulfillment with lines/quantities. GUARD: throws FulfillmentGuardException if financial_status not paid/partially_refunded\n- markAsShipped(Fulfillment, tracking): sets tracking, shipped status\n- markAsDelivered(Fulfillment): sets delivered status\n- Updates order.fulfillment_status (partial/fulfilled)\n\n### Events\nCreate: OrderCreated, OrderPaid, OrderFulfilled, OrderCancelled, OrderRefunded\n\n### Tests (target 50+ new tests)\n- MockPaymentProviderTest: magic cards, PayPal, bank transfer\n- OrderServiceTest: create from checkout, generate numbers, cancel\n- RefundServiceTest: partial/full refund, restock, validation\n- FulfillmentServiceTest: create, ship, deliver, guard check\n- PaymentServiceTest: charge flow, bank transfer confirmation\n\n## Rules\n- All monetary amounts as INTEGER in minor units\n- Use BelongsToStore on store-scoped models (Order)\n- Run `vendor/bin/pint --dirty --format agent` before finishing\n- Run `php artisan test --compact` to verify ALL tests pass\n- DO NOT modify existing test files. DO NOT create git commits.\n"},"uuid":"77e398b0-d41c-498b-85c9-c248d36b1dbe","timestamp":"2026-02-13T15:23:47.425Z"}
{"parentUuid":"77e398b0-d41c-498b-85c9-c248d36b1dbe","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_01LUsWcK3ZoS3W28gUhh5B2W","type":"message","role":"assistant","content":[{"type":"text","text":"\n\nI'll start by reading the relevant specs and understanding the existing codebase patterns before implementing."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6316,"cache_read_input_tokens":16158,"cache_creation":{"ephemeral_5m_input_tokens":6316,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JHKiAbTJ7trpMRug9i","type":"assistant","uuid":"cf3cd41c-c0a7-4555-8f0b-fba3eb634ce9","timestamp":"2026-02-13T15:23:50.055Z"}
{"parentUuid":"cf3cd41c-c0a7-4555-8f0b-fba3eb634ce9","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_01LUsWcK3ZoS3W28gUhh5B2W","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BDW42R1VxGUgxtYCL7oVFh","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6316,"cache_read_input_tokens":16158,"cache_creation":{"ephemeral_5m_input_tokens":6316,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JHKiAbTJ7trpMRug9i","type":"assistant","uuid":"10e540c7-3fc7-4812-bef4-6b8b0fca6b98","timestamp":"2026-02-13T15:23:50.678Z"}
{"parentUuid":"10e540c7-3fc7-4812-bef4-6b8b0fca6b98","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_01LUsWcK3ZoS3W28gUhh5B2W","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KYWNZB7mrU4c8X6y4LpMSM","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6316,"cache_read_input_tokens":16158,"cache_creation":{"ephemeral_5m_input_tokens":6316,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JHKiAbTJ7trpMRug9i","type":"assistant","uuid":"eff265da-84ba-4ffb-820f-1184927f7071","timestamp":"2026-02-13T15:23:51.092Z"}
{"parentUuid":"eff265da-84ba-4ffb-820f-1184927f7071","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01BDW42R1VxGUgxtYCL7oVFh","toolUseID":"toolu_01BDW42R1VxGUgxtYCL7oVFh","timestamp":"2026-02-13T15:23:51.135Z","uuid":"4c3d4ca1-2fc8-45f2-8d4b-79610d9c9868"}
{"parentUuid":"10e540c7-3fc7-4812-bef4-6b8b0fca6b98","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BDW42R1VxGUgxtYCL7oVFh","type":"tool_result","content":"     1→# 01 - Database Schema Specification\n     2→\n     3→> E-commerce platform - SQLite database schema for Laravel 12\n     4→\n     5→---\n     6→\n     7→## Conventions\n     8→\n     9→| Convention | Detail |\n    10→|---|---|\n    11→| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |\n    12→| Tenant isolation | `store_id` foreign key on every tenant-scoped table |\n    13→| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |\n    14→| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |\n    15→| JSON data | Text column - Laravel casts to/from JSON at the application layer |\n    16→| Enumerations | Check constraints on text columns |\n    17→| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |\n    18→| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |\n    19→| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |\n    20→\n    21→---\n    22→\n    23→## Epic 1 - Foundation and Multi-Store Tenancy\n    24→\n    25→### organizations\n    26→\n    27→Billing entity that owns one or more stores.\n    28→\n    29→| Column | Type | Nullable | Default | Constraints | Notes |\n    30→|---|---|---|---|---|---|\n    31→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n    32→| name | TEXT | No | - | - | Organization display name |\n    33→| billing_email | TEXT | No | - | - | Primary billing contact |\n    34→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n    35→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n    36→\n    37→**Indexes:**\n    38→\n    39→- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email\n    40→\n    41→---\n    42→\n    43→### stores\n    44→\n    45→Individual storefront belonging to an organization.\n    46→\n    47→| Column | Type | Nullable | Default | Constraints | Notes |\n    48→|---|---|---|---|---|---|\n    49→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n    50→| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |\n    51→| name | TEXT | No | - | - | Store display name |\n    52→| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |\n    53→| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |\n    54→| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n    55→| default_locale | TEXT | No | 'en' | - | IETF locale tag |\n    56→| timezone | TEXT | No | 'UTC' | - | IANA timezone |\n    57→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n    58→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n    59→\n    60→**Indexes:**\n    61→\n    62→- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing\n    63→- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org\n    64→- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status\n    65→\n    66→---\n    67→\n    68→### store_domains\n    69→\n    70→Custom domains attached to a store.\n    71→\n    72→| Column | Type | Nullable | Default | Constraints | Notes |\n    73→|---|---|---|---|---|---|\n    74→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n    75→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n    76→| hostname | TEXT | No | - | UNIQUE per table | e.g. \"shop.example.com\" |\n    77→| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |\n    78→| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |\n    79→| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |\n    80→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n    81→\n    82→**Indexes:**\n    83→\n    84→- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing\n    85→- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store\n    86→- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain\n    87→\n    88→---\n    89→\n    90→### users\n    91→\n    92→Admin/staff users who manage stores (not storefront customers).\n    93→\n    94→| Column | Type | Nullable | Default | Constraints | Notes |\n    95→|---|---|---|---|---|---|\n    96→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n    97→| email | TEXT | No | - | UNIQUE per table | Login email |\n    98→| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |\n    99→| name | TEXT | No | - | - | Display name |\n   100→| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n   101→| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |\n   102→| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   103→| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |\n   104→| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |\n   105→| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   106→| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |\n   107→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   108→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   109→\n   110→**Indexes:**\n   111→\n   112→- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup\n   113→- `idx_users_status` on (`status`) - Filtering users by active/disabled\n   114→\n   115→**Notes:** The `two_factor_*` columns align with the existing migration `2025_08_14_170933_add_two_factor_columns_to_users_table.php`. The `password_hash` column maps to Laravel's default `password` field internally but stores a bcrypt/argon2 hash.\n   116→\n   117→---\n   118→\n   119→### store_users\n   120→\n   121→Pivot table linking users to stores with a role. Uses composite primary key.\n   122→\n   123→| Column | Type | Nullable | Default | Constraints | Notes |\n   124→|---|---|---|---|---|---|\n   125→| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n   126→| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |\n   127→| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |\n   128→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   129→\n   130→**Primary Key:** (`store_id`, `user_id`)\n   131→\n   132→**Indexes:**\n   133→\n   134→- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user\n   135→- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store\n   136→\n   137→---\n   138→\n   139→### store_settings\n   140→\n   141→Single-row-per-store JSON settings bag.\n   142→\n   143→| Column | Type | Nullable | Default | Constraints | Notes |\n   144→|---|---|---|---|---|---|\n   145→| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n   146→| settings_json | TEXT | No | '{}' | - | JSON settings object |\n   147→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   148→\n   149→**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.\n   150→\n   151→---\n   152→\n   153→## Epic 2 - Catalog, Collections, Inventory, and Media\n   154→\n   155→### products\n   156→\n   157→Core product record, scoped to a store.\n   158→\n   159→| Column | Type | Nullable | Default | Constraints | Notes |\n   160→|---|---|---|---|---|---|\n   161→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   162→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   163→| title | TEXT | No | - | - | Product display title |\n   164→| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n   165→| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |\n   166→| description_html | TEXT | Yes | NULL | - | Rich-text description |\n   167→| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |\n   168→| product_type | TEXT | Yes | NULL | - | Category classification |\n   169→| tags | TEXT | No | '[]' | - | JSON array, e.g. [\"summer\",\"sale\"] |\n   170→| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |\n   171→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   172→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   173→\n   174→**Indexes:**\n   175→\n   176→- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n   177→- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store\n   178→- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store\n   179→- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date\n   180→- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor\n   181→- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type\n   182→\n   183→**Notes:** `tags` is a JSON-encoded array (e.g. `[\"summer\",\"sale\"]`). The `handle` is unique per store, not globally.\n   184→\n   185→---\n   186→\n   187→### product_options\n   188→\n   189→Named option dimensions for a product (e.g. Size, Color).\n   190→\n   191→| Column | Type | Nullable | Default | Constraints | Notes |\n   192→|---|---|---|---|---|---|\n   193→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   194→| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n   195→| name | TEXT | No | - | - | Option name, e.g. \"Size\", \"Color\" |\n   196→| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |\n   197→\n   198→**Indexes:**\n   199→\n   200→- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product\n   201→- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product\n   202→\n   203→---\n   204→\n   205→### product_option_values\n   206→\n   207→Individual values within a product option (e.g. \"Small\", \"Medium\", \"Large\").\n   208→\n   209→| Column | Type | Nullable | Default | Constraints | Notes |\n   210→|---|---|---|---|---|---|\n   211→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   212→| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |\n   213→| value | TEXT | No | - | - | Display value, e.g. \"Small\" |\n   214→| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |\n   215→\n   216→**Indexes:**\n   217→\n   218→- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option\n   219→- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option\n   220→\n   221→---\n   222→\n   223→### product_variants\n   224→\n   225→Purchasable SKU-level variant of a product.\n   226→\n   227→| Column | Type | Nullable | Default | Constraints | Notes |\n   228→|---|---|---|---|---|---|\n   229→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   230→| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n   231→| sku | TEXT | Yes | NULL | - | Stock keeping unit |\n   232→| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |\n   233→| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |\n   234→| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |\n   235→| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n   236→| weight_g | INTEGER | Yes | NULL | - | Weight in grams |\n   237→| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |\n   238→| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |\n   239→| position | INTEGER | No | 0 | - | Display ordering |\n   240→| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |\n   241→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   242→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   243→\n   244→**Indexes:**\n   245→\n   246→- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product\n   247→- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants\n   248→- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup\n   249→- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing\n   250→- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant\n   251→\n   252→**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).\n   253→\n   254→---\n   255→\n   256→### variant_option_values\n   257→\n   258→Maps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.\n   259→\n   260→| Column | Type | Nullable | Default | Constraints | Notes |\n   261→|---|---|---|---|---|---|\n   262→| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |\n   263→| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |\n   264→\n   265→**Primary Key:** (`variant_id`, `product_option_value_id`)\n   266→\n   267→**Indexes:**\n   268→\n   269→- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value\n   270→\n   271→**Notes:** This is a junction table that links variants to the specific option values they represent. Required to resolve which combination of options each variant corresponds to.\n   272→\n   273→---\n   274→\n   275→### inventory_items\n   276→\n   277→Stock tracking per variant per store.\n   278→\n   279→| Column | Type | Nullable | Default | Constraints | Notes |\n   280→|---|---|---|---|---|---|\n   281→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   282→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   283→| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |\n   284→| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |\n   285→| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |\n   286→| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |\n   287→\n   288→**Indexes:**\n   289→\n   290→- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup\n   291→- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store\n   292→\n   293→**Notes:** `quantity_on_hand` is the total physical stock. `quantity_reserved` is stock allocated to open orders not yet fulfilled. Available = on_hand - reserved. Policy `deny` prevents overselling; `continue` allows selling below zero.\n   294→\n   295→---\n   296→\n   297→### collections\n   298→\n   299→Curated or rule-based product groupings.\n   300→\n   301→| Column | Type | Nullable | Default | Constraints | Notes |\n   302→|---|---|---|---|---|---|\n   303→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   304→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   305→| title | TEXT | No | - | - | Collection display title |\n   306→| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n   307→| description_html | TEXT | Yes | NULL | - | Rich-text description |\n   308→| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |\n   309→| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |\n   310→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   311→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   312→\n   313→**Indexes:**\n   314→\n   315→- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n   316→- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store\n   317→- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status\n   318→\n   319→---\n   320→\n   321→### collection_products\n   322→\n   323→Pivot linking products to collections with ordering. Composite primary key.\n   324→\n   325→| Column | Type | Nullable | Default | Constraints | Notes |\n   326→|---|---|---|---|---|---|\n   327→| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |\n   328→| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |\n   329→| position | INTEGER | No | 0 | - | Display ordering within collection |\n   330→\n   331→**Primary Key:** (`collection_id`, `product_id`)\n   332→\n   333→**Indexes:**\n   334→\n   335→- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product\n   336→- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection\n   337→\n   338→---\n   339→\n   340→### product_media\n   341→\n   342→Images and videos attached to a product.\n   343→\n   344→| Column | Type | Nullable | Default | Constraints | Notes |\n   345→|---|---|---|---|---|---|\n   346→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   347→| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |\n   348→| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |\n   349→| storage_key | TEXT | No | - | - | File storage path/key |\n   350→| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |\n   351→| width | INTEGER | Yes | NULL | - | Pixel width |\n   352→| height | INTEGER | Yes | NULL | - | Pixel height |\n   353→| mime_type | TEXT | Yes | NULL | - | e.g. \"image/jpeg\" |\n   354→| byte_size | INTEGER | Yes | NULL | - | File size in bytes |\n   355→| position | INTEGER | No | 0 | - | Display ordering |\n   356→| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |\n   357→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   358→\n   359→**Indexes:**\n   360→\n   361→- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product\n   362→- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing\n   363→- `idx_product_media_status` on (`status`) - Finding media by processing status\n   364→\n   365→---\n   366→\n   367→## Epic 3 - Themes, Storefront, and Localization\n   368→\n   369→### themes\n   370→\n   371→Storefront theme definitions per store.\n   372→\n   373→| Column | Type | Nullable | Default | Constraints | Notes |\n   374→|---|---|---|---|---|---|\n   375→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   376→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   377→| name | TEXT | No | - | - | Theme display name |\n   378→| version | TEXT | Yes | NULL | - | Semantic version string |\n   379→| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |\n   380→| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   381→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   382→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   383→\n   384→**Indexes:**\n   385→\n   386→- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store\n   387→- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store\n   388→\n   389→---\n   390→\n   391→### theme_files\n   392→\n   393→Individual files belonging to a theme (templates, assets, snippets).\n   394→\n   395→| Column | Type | Nullable | Default | Constraints | Notes |\n   396→|---|---|---|---|---|---|\n   397→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   398→| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |\n   399→| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |\n   400→| storage_key | TEXT | No | - | - | File storage path/key |\n   401→| sha256 | TEXT | No | - | - | Content hash for cache busting |\n   402→| byte_size | INTEGER | No | 0 | - | File size in bytes |\n   403→\n   404→**Indexes:**\n   405→\n   406→- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme\n   407→- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme\n   408→\n   409→---\n   410→\n   411→### theme_settings\n   412→\n   413→Single-row JSON configuration per theme.\n   414→\n   415→| Column | Type | Nullable | Default | Constraints | Notes |\n   416→|---|---|---|---|---|---|\n   417→| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |\n   418→| settings_json | TEXT | No | '{}' | - | JSON settings object |\n   419→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   420→\n   421→---\n   422→\n   423→### pages\n   424→\n   425→Static CMS pages (About Us, Contact, etc.).\n   426→\n   427→| Column | Type | Nullable | Default | Constraints | Notes |\n   428→|---|---|---|---|---|---|\n   429→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   430→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   431→| title | TEXT | No | - | - | Page title |\n   432→| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |\n   433→| body_html | TEXT | Yes | NULL | - | Rich-text content |\n   434→| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |\n   435→| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   436→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   437→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   438→\n   439→**Indexes:**\n   440→\n   441→- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing\n   442→- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store\n   443→- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status\n   444→\n   445→---\n   446→\n   447→### navigation_menus\n   448→\n   449→Named menus (e.g. main-menu, footer-menu).\n   450→\n   451→| Column | Type | Nullable | Default | Constraints | Notes |\n   452→|---|---|---|---|---|---|\n   453→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   454→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   455→| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. \"main-menu\" |\n   456→| title | TEXT | No | - | - | Display title |\n   457→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   458→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   459→\n   460→**Indexes:**\n   461→\n   462→- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store\n   463→- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store\n   464→\n   465→---\n   466→\n   467→### navigation_items\n   468→\n   469→Individual links within a navigation menu (supports nesting via position).\n   470→\n   471→| Column | Type | Nullable | Default | Constraints | Notes |\n   472→|---|---|---|---|---|---|\n   473→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   474→| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |\n   475→| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |\n   476→| label | TEXT | No | - | - | Display label |\n   477→| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |\n   478→| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |\n   479→| position | INTEGER | No | 0 | - | Display ordering |\n   480→\n   481→**Indexes:**\n   482→\n   483→- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu\n   484→- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing\n   485→\n   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.\n   487→\n   488→---\n   489→\n   490→## Epic 4 - Search\n   491→\n   492→### search_settings\n   493→\n   494→Per-store search configuration (synonyms, stop words).\n   495→\n   496→| Column | Type | Nullable | Default | Constraints | Notes |\n   497→|---|---|---|---|---|---|\n   498→| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n   499→| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |\n   500→| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |\n   501→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   502→\n   503→---\n   504→\n   505→### search_queries\n   506→\n   507→Search query log for analytics and autocomplete.\n   508→\n   509→| Column | Type | Nullable | Default | Constraints | Notes |\n   510→|---|---|---|---|---|---|\n   511→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   512→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   513→| query | TEXT | No | - | - | Search query text |\n   514→| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |\n   515→| results_count | INTEGER | No | 0 | - | Number of results returned |\n   516→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   517→\n   518→**Indexes:**\n   519→\n   520→- `idx_search_queries_store_id` on (`store_id`) - FK lookup\n   521→- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store\n   522→- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups\n   523→\n   524→---\n   525→\n   526→## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes\n   527→\n   528→### carts\n   529→\n   530→Shopping cart, optionally linked to a customer.\n   531→\n   532→| Column | Type | Nullable | Default | Constraints | Notes |\n   533→|---|---|---|---|---|---|\n   534→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   535→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   536→| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |\n   537→| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n   538→| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |\n   539→| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |\n   540→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   541→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   542→\n   543→**Indexes:**\n   544→\n   545→- `idx_carts_store_id` on (`store_id`) - FK lookup\n   546→- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer\n   547→- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)\n   548→\n   549→**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.\n   550→\n   551→---\n   552→\n   553→### cart_lines\n   554→\n   555→Individual line items within a cart.\n   556→\n   557→| Column | Type | Nullable | Default | Constraints | Notes |\n   558→|---|---|---|---|---|---|\n   559→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   560→| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |\n   561→| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |\n   562→| quantity | INTEGER | No | 1 | - | Number of units |\n   563→| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n   564→| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |\n   565→| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |\n   566→| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |\n   567→\n   568→**Indexes:**\n   569→\n   570→- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart\n   571→- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart\n   572→\n   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`.\n   574→\n   575→---\n   576→\n   577→### checkouts\n   578→\n   579→Checkout session progressing through address, shipping, payment steps.\n   580→\n   581→| Column | Type | Nullable | Default | Constraints | Notes |\n   582→|---|---|---|---|---|---|\n   583→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   584→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   585→| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |\n   586→| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |\n   587→| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |\n   588→| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |\n   589→| email | TEXT | Yes | NULL | - | Contact email |\n   590→| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |\n   591→| billing_address_json | TEXT | Yes | NULL | - | JSON address object |\n   592→| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |\n   593→| discount_code | TEXT | Yes | NULL | - | Applied discount code |\n   594→| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |\n   595→| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |\n   596→| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |\n   597→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   598→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   599→\n   600→**Indexes:**\n   601→\n   602→- `idx_checkouts_store_id` on (`store_id`) - FK lookup\n   603→- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart\n   604→- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer\n   605→- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status\n   606→- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts\n   607→\n   608→**Notes:** `totals_json` contains a snapshot: `{\"subtotal\": 5000, \"discount\": 500, \"shipping\": 799, \"tax\": 430, \"total\": 5729}`. `tax_provider_snapshot_json` stores the full tax calculation response for audit purposes.\n   609→\n   610→---\n   611→\n   612→### shipping_zones\n   613→\n   614→Geographic shipping zones for a store.\n   615→\n   616→| Column | Type | Nullable | Default | Constraints | Notes |\n   617→|---|---|---|---|---|---|\n   618→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   619→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   620→| name | TEXT | No | - | - | Zone display name |\n   621→| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. [\"US\",\"CA\"] |\n   622→| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. [\"US-NY\",\"US-CA\"] |\n   623→\n   624→**Indexes:**\n   625→\n   626→- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store\n   627→\n   628→**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.\n   629→\n   630→---\n   631→\n   632→### shipping_rates\n   633→\n   634→Shipping rate options within a zone.\n   635→\n   636→| Column | Type | Nullable | Default | Constraints | Notes |\n   637→|---|---|---|---|---|---|\n   638→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   639→| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |\n   640→| name | TEXT | No | - | - | Rate display name |\n   641→| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |\n   642→| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |\n   643→| is_active | INTEGER | No | 1 | - | Boolean: active flag |\n   644→\n   645→**Indexes:**\n   646→\n   647→- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone\n   648→- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone\n   649→\n   650→**Notes:** `config_json` structure depends on `type`:\n   651→- Flat: `{\"amount\": 799}`\n   652→- Weight: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 500, \"amount\": 499}]}`\n   653→- Price: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 799}]}`\n   654→- Carrier: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n   655→\n   656→---\n   657→\n   658→### tax_settings\n   659→\n   660→Per-store tax configuration.\n   661→\n   662→| Column | Type | Nullable | Default | Constraints | Notes |\n   663→|---|---|---|---|---|---|\n   664→| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |\n   665→| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |\n   666→| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |\n   667→| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |\n   668→| config_json | TEXT | No | '{}' | - | Provider-specific config |\n   669→\n   670→---\n   671→\n   672→### discounts\n   673→\n   674→Discount codes and automatic discounts.\n   675→\n   676→| Column | Type | Nullable | Default | Constraints | Notes |\n   677→|---|---|---|---|---|---|\n   678→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   679→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   680→| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |\n   681→| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |\n   682→| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |\n   683→| value_amount | INTEGER | No | 0 | - | See notes for interpretation |\n   684→| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |\n   685→| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |\n   686→| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |\n   687→| usage_count | INTEGER | No | 0 | - | Current redemption count |\n   688→| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |\n   689→| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |\n   690→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   691→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   692→\n   693→**Indexes:**\n   694→\n   695→- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store\n   696→- `idx_discounts_store_id` on (`store_id`) - FK lookup\n   697→- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status\n   698→- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic\n   699→\n   700→**Notes:** `value_amount` interpretation depends on `value_type`. For `percent`, it is a whole number (e.g. 15 = 15%). For `fixed`, it is in minor units (cents). For `free_shipping`, it is ignored. `rules_json` may contain minimum purchase amounts, applicable product/collection IDs, customer eligibility, etc.\n   701→\n   702→---\n   703→\n   704→## Epic 6 - Payments, Orders, Fulfillment, and Customers\n   705→\n   706→### customers\n   707→\n   708→Storefront customer accounts, scoped to a store.\n   709→\n   710→| Column | Type | Nullable | Default | Constraints | Notes |\n   711→|---|---|---|---|---|---|\n   712→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   713→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   714→| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |\n   715→| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |\n   716→| name | TEXT | Yes | NULL | - | Display name |\n   717→| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |\n   718→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   719→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   720→\n   721→**Indexes:**\n   722→\n   723→- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store\n   724→- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store\n   725→\n   726→**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.\n   727→\n   728→---\n   729→\n   730→### customer_addresses\n   731→\n   732→Saved addresses for a customer.\n   733→\n   734→| Column | Type | Nullable | Default | Constraints | Notes |\n   735→|---|---|---|---|---|---|\n   736→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   737→| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |\n   738→| label | TEXT | Yes | NULL | - | User label, e.g. \"Home\", \"Work\" |\n   739→| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |\n   740→| is_default | INTEGER | No | 0 | - | Boolean: default address flag |\n   741→\n   742→**Indexes:**\n   743→\n   744→- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer\n   745→- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address\n   746→\n   747→**Notes:** `address_json` structure: `{\"first_name\": \"\", \"last_name\": \"\", \"company\": \"\", \"address1\": \"\", \"address2\": \"\", \"city\": \"\", \"province\": \"\", \"province_code\": \"\", \"country\": \"\", \"country_code\": \"\", \"zip\": \"\", \"phone\": \"\"}`.\n   748→\n   749→---\n   750→\n   751→### orders\n   752→\n   753→Placed orders with financial and fulfillment tracking.\n   754→\n   755→| Column | Type | Nullable | Default | Constraints | Notes |\n   756→|---|---|---|---|---|---|\n   757→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   758→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   759→| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |\n   760→| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |\n   761→| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |\n   762→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |\n   763→| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |\n   764→| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |\n   765→| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n   766→| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |\n   767→| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |\n   768→| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |\n   769→| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |\n   770→| total_amount | INTEGER | No | 0 | - | Grand total in minor units |\n   771→| email | TEXT | Yes | NULL | - | Contact email at time of order |\n   772→| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n   773→| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |\n   774→| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |\n   775→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   776→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   777→\n   778→**Indexes:**\n   779→\n   780→- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store\n   781→- `idx_orders_store_id` on (`store_id`) - FK lookup\n   782→- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer\n   783→- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status\n   784→- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status\n   785→- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status\n   786→- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries\n   787→\n   788→---\n   789→\n   790→### order_lines\n   791→\n   792→Individual items within an order. Contains snapshots of product data at time of purchase.\n   793→\n   794→| Column | Type | Nullable | Default | Constraints | Notes |\n   795→|---|---|---|---|---|---|\n   796→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   797→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   798→| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |\n   799→| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |\n   800→| title_snapshot | TEXT | No | - | - | Product title at time of purchase |\n   801→| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |\n   802→| quantity | INTEGER | No | 1 | - | Number of units |\n   803→| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |\n   804→| total_amount | INTEGER | No | 0 | - | Line total in minor units |\n   805→| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |\n   806→| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |\n   807→\n   808→**Indexes:**\n   809→\n   810→- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order\n   811→- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product\n   812→- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant\n   813→\n   814→**Notes:** `product_id` and `variant_id` are nullable with SET NULL on delete so that order history survives product deletion. `tax_lines_json` example: `[{\"title\": \"State Tax\", \"rate\": 800, \"amount\": 400}]` (rate in basis points, e.g. 800 = 8%). `discount_allocations_json` example: `[{\"discount_id\": 5, \"amount\": 250}]`.\n   815→\n   816→---\n   817→\n   818→### payments\n   819→\n   820→Payment attempts and captures for an order.\n   821→\n   822→| Column | Type | Nullable | Default | Constraints | Notes |\n   823→|---|---|---|---|---|---|\n   824→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   825→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   826→| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |\n   827→| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |\n   828→| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |\n   829→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'captured', 'failed', 'refunded') | |\n   830→| amount | INTEGER | No | 0 | - | Payment amount in minor units |\n   831→| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |\n   832→| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |\n   833→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   834→\n   835→**Indexes:**\n   836→\n   837→- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order\n   838→- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Payment reconciliation by provider ID\n   839→- `idx_payments_method` on (`method`) - Filtering payments by method\n   840→- `idx_payments_status` on (`status`) - Filtering payments by status\n   841→\n   842→**Notes:** `raw_json_encrypted` stores the mock payment provider response, encrypted at the application layer. `amount` is in minor units (cents). The `provider` is always `mock` since the platform uses a mock PSP (no real payment gateway integration). The `method` column records which payment method the customer selected (credit_card, paypal, or bank_transfer).\n   843→\n   844→---\n   845→\n   846→### refunds\n   847→\n   848→Refund records linked to an order and payment.\n   849→\n   850→| Column | Type | Nullable | Default | Constraints | Notes |\n   851→|---|---|---|---|---|---|\n   852→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   853→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   854→| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |\n   855→| amount | INTEGER | No | 0 | - | Refund amount in minor units |\n   856→| reason | TEXT | Yes | NULL | - | Refund reason |\n   857→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |\n   858→| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |\n   859→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   860→\n   861→**Indexes:**\n   862→\n   863→- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order\n   864→- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment\n   865→- `idx_refunds_status` on (`status`) - Filtering refunds by status\n   866→\n   867→---\n   868→\n   869→### fulfillments\n   870→\n   871→Shipment tracking for order fulfillment.\n   872→\n   873→| Column | Type | Nullable | Default | Constraints | Notes |\n   874→|---|---|---|---|---|---|\n   875→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   876→| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |\n   877→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |\n   878→| tracking_company | TEXT | Yes | NULL | - | Carrier name |\n   879→| tracking_number | TEXT | Yes | NULL | - | Tracking number |\n   880→| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |\n   881→| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   882→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   883→\n   884→**Indexes:**\n   885→\n   886→- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order\n   887→- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status\n   888→- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup\n   889→\n   890→---\n   891→\n   892→### fulfillment_lines\n   893→\n   894→Links specific order lines and quantities to a fulfillment.\n   895→\n   896→| Column | Type | Nullable | Default | Constraints | Notes |\n   897→|---|---|---|---|---|---|\n   898→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   899→| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |\n   900→| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |\n   901→| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |\n   902→\n   903→**Indexes:**\n   904→\n   905→- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment\n   906→- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment\n   907→\n   908→**Notes:** An order with multiple line items may be partially fulfilled. This table tracks how many units of each line item are included in each shipment.\n   909→\n   910→---\n   911→\n   912→## Epic 7 - Analytics\n   913→\n   914→### analytics_events\n   915→\n   916→Raw event stream for storefront analytics.\n   917→\n   918→| Column | Type | Nullable | Default | Constraints | Notes |\n   919→|---|---|---|---|---|---|\n   920→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   921→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n   922→| type | TEXT | No | - | - | Event type (see notes) |\n   923→| session_id | TEXT | Yes | NULL | - | Browser session identifier |\n   924→| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |\n   925→| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |\n   926→| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |\n   927→| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |\n   928→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   929→\n   930→**Indexes:**\n   931→\n   932→- `idx_analytics_events_store_id` on (`store_id`) - FK lookup\n   933→- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store\n   934→- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries\n   935→- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping\n   936→- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history\n   937→- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication\n   938→\n   939→**Notes:** Event types include: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. This table will grow fast - consider periodic archiving or partitioning by date for production workloads beyond SQLite.\n   940→\n   941→---\n   942→\n   943→### analytics_daily\n   944→\n   945→Pre-aggregated daily metrics per store. Composite primary key.\n   946→\n   947→| Column | Type | Nullable | Default | Constraints | Notes |\n   948→|---|---|---|---|---|---|\n   949→| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |\n   950→| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |\n   951→| orders_count | INTEGER | No | 0 | - | Number of orders placed |\n   952→| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |\n   953→| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |\n   954→| visits_count | INTEGER | No | 0 | - | Unique sessions |\n   955→| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |\n   956→| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |\n   957→| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |\n   958→\n   959→**Primary Key:** (`store_id`, `date`)\n   960→\n   961→**Indexes:**\n   962→\n   963→- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)\n   964→\n   965→**Notes:** `date` is an ISO-8601 date string (YYYY-MM-DD). `revenue_amount` and `aov_amount` (average order value) are in minor units (cents). This table is populated by a scheduled aggregation job.\n   966→\n   967→---\n   968→\n   969→## Epic 8 - Apps and Webhooks\n   970→\n   971→### apps\n   972→\n   973→Third-party or first-party application registry.\n   974→\n   975→| Column | Type | Nullable | Default | Constraints | Notes |\n   976→|---|---|---|---|---|---|\n   977→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   978→| name | TEXT | No | - | - | App display name |\n   979→| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |\n   980→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n   981→\n   982→**Indexes:**\n   983→\n   984→- `idx_apps_status` on (`status`) - Filtering apps by status\n   985→\n   986→---\n   987→\n   988→### app_installations\n   989→\n   990→Installation of an app on a specific store.\n   991→\n   992→| Column | Type | Nullable | Default | Constraints | Notes |\n   993→|---|---|---|---|---|---|\n   994→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n   995→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |\n   996→| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |\n   997→| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |\n   998→| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |\n   999→| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1000→\n  1001→**Indexes:**\n  1002→\n  1003→- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store\n  1004→- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store\n  1005→- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed\n  1006→\n  1007→---\n  1008→\n  1009→### oauth_clients\n  1010→\n  1011→OAuth2 client credentials for apps.\n  1012→\n  1013→| Column | Type | Nullable | Default | Constraints | Notes |\n  1014→|---|---|---|---|---|---|\n  1015→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1016→| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |\n  1017→| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |\n  1018→| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |\n  1019→| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |\n  1020→\n  1021→**Indexes:**\n  1022→\n  1023→- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup\n  1024→- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app\n  1025→\n  1026→---\n  1027→\n  1028→### oauth_tokens\n  1029→\n  1030→Issued access/refresh tokens for app installations.\n  1031→\n  1032→| Column | Type | Nullable | Default | Constraints | Notes |\n  1033→|---|---|---|---|---|---|\n  1034→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1035→| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |\n  1036→| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |\n  1037→| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |\n  1038→| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |\n  1039→\n  1040→**Indexes:**\n  1041→\n  1042→- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation\n  1043→- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup\n  1044→- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens\n  1045→\n  1046→---\n  1047→\n  1048→### webhook_subscriptions\n  1049→\n  1050→Registered webhook endpoints for event delivery.\n  1051→\n  1052→| Column | Type | Nullable | Default | Constraints | Notes |\n  1053→|---|---|---|---|---|---|\n  1054→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1055→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n  1056→| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |\n  1057→| event_type | TEXT | No | - | - | Event to subscribe to |\n  1058→| target_url | TEXT | No | - | - | Delivery endpoint URL |\n  1059→| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |\n  1060→| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |\n  1061→\n  1062→**Indexes:**\n  1063→\n  1064→- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup\n  1065→- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event\n  1066→- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation\n  1067→\n  1068→---\n  1069→\n  1070→### webhook_deliveries\n  1071→\n  1072→Delivery log for webhook attempts.\n  1073→\n  1074→| Column | Type | Nullable | Default | Constraints | Notes |\n  1075→|---|---|---|---|---|---|\n  1076→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1077→| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |\n  1078→| event_id | TEXT | No | - | - | Unique event identifier |\n  1079→| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |\n  1080→| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |\n  1081→| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1082→| response_code | INTEGER | Yes | NULL | - | HTTP response code |\n  1083→| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |\n  1084→\n  1085→**Indexes:**\n  1086→\n  1087→- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription\n  1088→- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event\n  1089→- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry\n  1090→- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling\n  1091→\n  1092→---\n  1093→\n  1094→## Laravel Framework Tables\n  1095→\n  1096→The following tables are managed by Laravel's built-in migrations and should not be modified.\n  1097→\n  1098→### sessions\n  1099→\n  1100→| Column | Type | Nullable | Default | Constraints | Notes |\n  1101→|---|---|---|---|---|---|\n  1102→| id | TEXT | No | - | PRIMARY KEY | Session ID |\n  1103→| user_id | INTEGER | Yes | NULL | - | Associated user |\n  1104→| ip_address | TEXT | Yes | NULL | - | Client IP |\n  1105→| user_agent | TEXT | Yes | NULL | - | Client user agent |\n  1106→| payload | TEXT | No | - | - | Serialized session data |\n  1107→| last_activity | INTEGER | No | - | - | Unix timestamp |\n  1108→\n  1109→### cache\n  1110→\n  1111→| Column | Type | Nullable | Default | Constraints | Notes |\n  1112→|---|---|---|---|---|---|\n  1113→| key | TEXT | No | - | PRIMARY KEY | Cache key |\n  1114→| value | TEXT | No | - | - | Serialized cache value |\n  1115→| expiration | INTEGER | No | - | - | Unix timestamp |\n  1116→\n  1117→### cache_locks\n  1118→\n  1119→| Column | Type | Nullable | Default | Constraints | Notes |\n  1120→|---|---|---|---|---|---|\n  1121→| key | TEXT | No | - | PRIMARY KEY | Lock key |\n  1122→| owner | TEXT | No | - | - | Lock owner identifier |\n  1123→| expiration | INTEGER | No | - | - | Unix timestamp |\n  1124→\n  1125→### jobs\n  1126→\n  1127→| Column | Type | Nullable | Default | Constraints | Notes |\n  1128→|---|---|---|---|---|---|\n  1129→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1130→| queue | TEXT | No | - | - | Queue name |\n  1131→| payload | TEXT | No | - | - | Serialized job payload |\n  1132→| attempts | INTEGER | No | - | - | Number of attempts |\n  1133→| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |\n  1134→| available_at | INTEGER | No | - | - | Unix timestamp |\n  1135→| created_at | INTEGER | No | - | - | Unix timestamp |\n  1136→\n  1137→### job_batches\n  1138→\n  1139→| Column | Type | Nullable | Default | Constraints | Notes |\n  1140→|---|---|---|---|---|---|\n  1141→| id | TEXT | No | - | PRIMARY KEY | Batch UUID |\n  1142→| name | TEXT | No | - | - | Batch name |\n  1143→| total_jobs | INTEGER | No | - | - | Total jobs in batch |\n  1144→| pending_jobs | INTEGER | No | - | - | Pending count |\n  1145→| failed_jobs | INTEGER | No | - | - | Failed count |\n  1146→| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |\n  1147→| options | TEXT | Yes | NULL | - | Serialized batch options |\n  1148→| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |\n  1149→| created_at | INTEGER | No | - | - | Unix timestamp |\n  1150→| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |\n  1151→\n  1152→### failed_jobs\n  1153→\n  1154→| Column | Type | Nullable | Default | Constraints | Notes |\n  1155→|---|---|---|---|---|---|\n  1156→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1157→| uuid | TEXT | No | - | UNIQUE | Job UUID |\n  1158→| connection | TEXT | No | - | - | Queue connection name |\n  1159→| queue | TEXT | No | - | - | Queue name |\n  1160→| payload | TEXT | No | - | - | Serialized job payload |\n  1161→| exception | TEXT | No | - | - | Exception trace |\n  1162→| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |\n  1163→\n  1164→### personal_access_tokens\n  1165→\n  1166→| Column | Type | Nullable | Default | Constraints | Notes |\n  1167→|---|---|---|---|---|---|\n  1168→| id | INTEGER | No | autoincrement | PRIMARY KEY | |\n  1169→| tokenable_type | TEXT | No | - | - | Polymorphic type |\n  1170→| tokenable_id | INTEGER | No | - | - | Polymorphic ID |\n  1171→| name | TEXT | No | - | - | Token name |\n  1172→| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |\n  1173→| abilities | TEXT | Yes | NULL | - | JSON array of abilities |\n  1174→| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1175→| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1176→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1177→| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1178→\n  1179→### password_reset_tokens\n  1180→\n  1181→| Column | Type | Nullable | Default | Constraints | Notes |\n  1182→|---|---|---|---|---|---|\n  1183→| email | TEXT | No | - | PRIMARY KEY | Admin user email |\n  1184→| token | TEXT | No | - | - | Hashed reset token |\n  1185→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1186→\n  1187→---\n  1188→\n  1189→### customer_password_reset_tokens\n  1190→\n  1191→| Column | Type | Nullable | Default | Constraints | Notes |\n  1192→|---|---|---|---|---|---|\n  1193→| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |\n  1194→| email | TEXT | No | - | - | Customer email |\n  1195→| token | TEXT | No | - | - | Hashed reset token |\n  1196→| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |\n  1197→\n  1198→**Primary Key:** (`store_id`, `email`)\n  1199→\n  1200→---\n  1201→\n  1202→## Entity Relationship Diagrams\n  1203→\n  1204→### Epic 1 - Foundation and Tenancy\n  1205→\n  1206→```mermaid\n  1207→erDiagram\n  1208→    organizations ||--o{ stores : \"owns\"\n  1209→    stores ||--o{ store_domains : \"has\"\n  1210→    stores ||--o{ store_users : \"links\"\n  1211→    users ||--o{ store_users : \"links\"\n  1212→    stores ||--|| store_settings : \"has\"\n  1213→\n  1214→    organizations {\n  1215→        INTEGER id PK\n  1216→        TEXT name\n  1217→        TEXT billing_email\n  1218→    }\n  1219→    stores {\n  1220→        INTEGER id PK\n  1221→        INTEGER organization_id FK\n  1222→        TEXT name\n  1223→        TEXT handle\n  1224→        TEXT status\n  1225→    }\n  1226→    store_domains {\n  1227→        INTEGER id PK\n  1228→        INTEGER store_id FK\n  1229→        TEXT hostname\n  1230→        TEXT type\n  1231→        INTEGER is_primary\n  1232→    }\n  1233→    users {\n  1234→        INTEGER id PK\n  1235→        TEXT email\n  1236→        TEXT password_hash\n  1237→        TEXT name\n  1238→        TEXT status\n  1239→    }\n  1240→    store_users {\n  1241→        INTEGER store_id PK\n  1242→        INTEGER user_id PK\n  1243→        TEXT role\n  1244→    }\n  1245→    store_settings {\n  1246→        INTEGER store_id PK\n  1247→        TEXT settings_json\n  1248→    }\n  1249→```\n  1250→\n  1251→### Epic 2 - Catalog, Collections, Inventory, and Media\n  1252→\n  1253→```mermaid\n  1254→erDiagram\n  1255→    stores ||--o{ products : \"has\"\n  1256→    stores ||--o{ collections : \"has\"\n  1257→    products ||--o{ product_options : \"defines\"\n  1258→    product_options ||--o{ product_option_values : \"contains\"\n  1259→    products ||--o{ product_variants : \"has\"\n  1260→    product_variants }o--o{ product_option_values : \"variant_option_values\"\n  1261→    product_variants ||--|| inventory_items : \"tracked by\"\n  1262→    stores ||--o{ inventory_items : \"has\"\n  1263→    collections }o--o{ products : \"collection_products\"\n  1264→    products ||--o{ product_media : \"has\"\n  1265→\n  1266→    products {\n  1267→        INTEGER id PK\n  1268→        INTEGER store_id FK\n  1269→        TEXT title\n  1270→        TEXT handle\n  1271→        TEXT status\n  1272→    }\n  1273→    product_options {\n  1274→        INTEGER id PK\n  1275→        INTEGER product_id FK\n  1276→        TEXT name\n  1277→        INTEGER position\n  1278→    }\n  1279→    product_option_values {\n  1280→        INTEGER id PK\n  1281→        INTEGER product_option_id FK\n  1282→        TEXT value\n  1283→        INTEGER position\n  1284→    }\n  1285→    product_variants {\n  1286→        INTEGER id PK\n  1287→        INTEGER product_id FK\n  1288→        TEXT sku\n  1289→        INTEGER price_amount\n  1290→        TEXT status\n  1291→    }\n  1292→    inventory_items {\n  1293→        INTEGER id PK\n  1294→        INTEGER store_id FK\n  1295→        INTEGER variant_id FK\n  1296→        INTEGER quantity_on_hand\n  1297→        INTEGER quantity_reserved\n  1298→        TEXT policy\n  1299→    }\n  1300→    collections {\n  1301→        INTEGER id PK\n  1302→        INTEGER store_id FK\n  1303→        TEXT title\n  1304→        TEXT handle\n  1305→        TEXT type\n  1306→        TEXT status\n  1307→    }\n  1308→    product_media {\n  1309→        INTEGER id PK\n  1310→        INTEGER product_id FK\n  1311→        TEXT type\n  1312→        TEXT storage_key\n  1313→        INTEGER position\n  1314→        TEXT status\n  1315→    }\n  1316→```\n  1317→\n  1318→### Epic 3 - Themes, Storefront, and Localization\n  1319→\n  1320→```mermaid\n  1321→erDiagram\n  1322→    stores ||--o{ themes : \"has\"\n  1323→    themes ||--o{ theme_files : \"contains\"\n  1324→    themes ||--|| theme_settings : \"configured by\"\n  1325→    stores ||--o{ pages : \"has\"\n  1326→    stores ||--o{ navigation_menus : \"has\"\n  1327→    navigation_menus ||--o{ navigation_items : \"contains\"\n  1328→\n  1329→    themes {\n  1330→        INTEGER id PK\n  1331→        INTEGER store_id FK\n  1332→        TEXT name\n  1333→        TEXT status\n  1334→    }\n  1335→    theme_files {\n  1336→        INTEGER id PK\n  1337→        INTEGER theme_id FK\n  1338→        TEXT path\n  1339→        TEXT storage_key\n  1340→    }\n  1341→    theme_settings {\n  1342→        INTEGER theme_id PK\n  1343→        TEXT settings_json\n  1344→    }\n  1345→    pages {\n  1346→        INTEGER id PK\n  1347→        INTEGER store_id FK\n  1348→        TEXT title\n  1349→        TEXT handle\n  1350→        TEXT status\n  1351→    }\n  1352→    navigation_menus {\n  1353→        INTEGER id PK\n  1354→        INTEGER store_id FK\n  1355→        TEXT handle\n  1356→        TEXT title\n  1357→    }\n  1358→    navigation_items {\n  1359→        INTEGER id PK\n  1360→        INTEGER menu_id FK\n  1361→        TEXT type\n  1362→        TEXT label\n  1363→        INTEGER position\n  1364→    }\n  1365→```\n  1366→\n  1367→### Epics 5-6 - Cart, Checkout, Orders, and Payments\n  1368→\n  1369→```mermaid\n  1370→erDiagram\n  1371→    stores ||--o{ customers : \"has\"\n  1372→    customers ||--o{ customer_addresses : \"has\"\n  1373→    stores ||--o{ carts : \"has\"\n  1374→    customers ||--o{ carts : \"owns\"\n  1375→    carts ||--o{ cart_lines : \"contains\"\n  1376→    product_variants ||--o{ cart_lines : \"referenced by\"\n  1377→    stores ||--o{ checkouts : \"has\"\n  1378→    carts ||--o{ checkouts : \"converted to\"\n  1379→    stores ||--o{ orders : \"has\"\n  1380→    customers ||--o{ orders : \"placed\"\n  1381→    orders ||--o{ order_lines : \"contains\"\n  1382→    orders ||--o{ payments : \"paid via\"\n  1383→    orders ||--o{ refunds : \"refunded via\"\n  1384→    payments ||--o{ refunds : \"source of\"\n  1385→    orders ||--o{ fulfillments : \"shipped via\"\n  1386→    fulfillments ||--o{ fulfillment_lines : \"contains\"\n  1387→    order_lines ||--o{ fulfillment_lines : \"fulfilled by\"\n  1388→\n  1389→    customers {\n  1390→        INTEGER id PK\n  1391→        INTEGER store_id FK\n  1392→        TEXT email\n  1393→        TEXT name\n  1394→    }\n  1395→    carts {\n  1396→        INTEGER id PK\n  1397→        INTEGER store_id FK\n  1398→        INTEGER customer_id FK\n  1399→        TEXT status\n  1400→    }\n  1401→    orders {\n  1402→        INTEGER id PK\n  1403→        INTEGER store_id FK\n  1404→        INTEGER customer_id FK\n  1405→        TEXT order_number\n  1406→        TEXT payment_method\n  1407→        TEXT status\n  1408→        TEXT financial_status\n  1409→        TEXT fulfillment_status\n  1410→        INTEGER total_amount\n  1411→    }\n  1412→    payments {\n  1413→        INTEGER id PK\n  1414→        INTEGER order_id FK\n  1415→        TEXT provider\n  1416→        TEXT method\n  1417→        TEXT status\n  1418→        INTEGER amount\n  1419→    }\n  1420→    fulfillments {\n  1421→        INTEGER id PK\n  1422→        INTEGER order_id FK\n  1423→        TEXT status\n  1424→        TEXT tracking_number\n  1425→    }\n  1426→```\n  1427→\n  1428→### Epics 7-8 - Analytics, Apps, and Webhooks\n  1429→\n  1430→```mermaid\n  1431→erDiagram\n  1432→    stores ||--o{ analytics_events : \"records\"\n  1433→    stores ||--o{ analytics_daily : \"aggregates\"\n  1434→    customers ||--o{ analytics_events : \"generates\"\n  1435→    apps ||--o{ app_installations : \"installed via\"\n  1436→    stores ||--o{ app_installations : \"has\"\n  1437→    apps ||--o{ oauth_clients : \"authenticates via\"\n  1438→    app_installations ||--o{ oauth_tokens : \"issues\"\n  1439→    stores ||--o{ webhook_subscriptions : \"registers\"\n  1440→    app_installations ||--o{ webhook_subscriptions : \"owns\"\n  1441→    webhook_subscriptions ||--o{ webhook_deliveries : \"delivers\"\n  1442→\n  1443→    analytics_events {\n  1444→        INTEGER id PK\n  1445→        INTEGER store_id FK\n  1446→        TEXT type\n  1447→        TEXT session_id\n  1448→    }\n  1449→    analytics_daily {\n  1450→        INTEGER store_id PK\n  1451→        TEXT date PK\n  1452→        INTEGER orders_count\n  1453→        INTEGER revenue_amount\n  1454→    }\n  1455→    apps {\n  1456→        INTEGER id PK\n  1457→        TEXT name\n  1458→        TEXT status\n  1459→    }\n  1460→    app_installations {\n  1461→        INTEGER id PK\n  1462→        INTEGER store_id FK\n  1463→        INTEGER app_id FK\n  1464→        TEXT status\n  1465→    }\n  1466→    webhook_subscriptions {\n  1467→        INTEGER id PK\n  1468→        INTEGER store_id FK\n  1469→        TEXT event_type\n  1470→        TEXT target_url\n  1471→    }\n  1472→    webhook_deliveries {\n  1473→        INTEGER id PK\n  1474→        INTEGER subscription_id FK\n  1475→        TEXT event_id\n  1476→        TEXT status\n  1477→    }\n  1478→```\n  1479→\n  1480→---\n  1481→\n  1482→## SQLite-Specific Configuration Requirements\n  1483→\n  1484→The application requires specific SQLite PRAGMA settings configured in the Laravel database configuration file (`config/database.php`). These settings must be applied for correct and performant operation.\n  1485→\n  1486→### Required PRAGMA Settings\n  1487→\n  1488→| Pragma | Value | Purpose |\n  1489→|---|---|---|\n  1490→| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |\n  1491→| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |\n  1492→| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |\n  1493→| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |\n  1494→| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |\n  1495→\n  1496→### Laravel Configuration Requirements\n  1497→\n  1498→- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.\n  1499→- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.\n  1500→- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.\n  1501→- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.\n  1502→- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.\n  1503→- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.\n  1504→\n  1505→---\n  1506→\n  1507→## Migration Dependency Graph\n  1508→\n  1509→Migrations must be created in an order that satisfies foreign key dependencies. The following flowchart shows the required sequence grouped into batches. Tables within the same batch have no inter-dependencies and can appear in any order.\n  1510→\n  1511→```mermaid\n  1512→flowchart TD\n  1513→    subgraph \"Batch 1 - No Dependencies\"\n  1514→        organizations\n  1515→        apps\n  1516→    end\n  1517→\n  1518→    subgraph \"Batch 2 - Depends on Batch 1\"\n  1519→        stores\n  1520→        users\n  1521→    end\n  1522→\n  1523→    subgraph \"Batch 3 - Depends on Batch 2\"\n  1524→        store_domains\n  1525→        store_users\n  1526→        store_settings\n  1527→        customers\n  1528→        themes\n  1529→        pages\n  1530→        navigation_menus\n  1531→        search_settings\n  1532→        shipping_zones\n  1533→        tax_settings\n  1534→        discounts\n  1535→        products\n  1536→        collections\n  1537→        app_installations\n  1538→        oauth_clients\n  1539→    end\n  1540→\n  1541→    subgraph \"Batch 4 - Depends on Batch 3\"\n  1542→        product_options\n  1543→        product_variants\n  1544→        product_media\n  1545→        collection_products\n  1546→        customer_addresses\n  1547→        carts\n  1548→        navigation_items\n  1549→        theme_files\n  1550→        theme_settings\n  1551→        search_queries\n  1552→        shipping_rates\n  1553→        oauth_tokens\n  1554→        webhook_subscriptions\n  1555→    end\n  1556→\n  1557→    subgraph \"Batch 5 - Depends on Batch 4\"\n  1558→        product_option_values\n  1559→        inventory_items\n  1560→        cart_lines\n  1561→        checkouts\n  1562→        orders\n  1563→        analytics_events\n  1564→        analytics_daily\n  1565→        webhook_deliveries\n  1566→    end\n  1567→\n  1568→    subgraph \"Batch 6 - Depends on Batch 5\"\n  1569→        variant_option_values\n  1570→        order_lines\n  1571→        payments\n  1572→        fulfillments\n  1573→    end\n  1574→\n  1575→    subgraph \"Batch 7 - Depends on Batch 6\"\n  1576→        refunds\n  1577→        fulfillment_lines\n  1578→    end\n  1579→\n  1580→    organizations --> stores\n  1581→    apps --> app_installations\n  1582→    apps --> oauth_clients\n  1583→    stores --> store_domains\n  1584→    stores --> store_users\n  1585→    users --> store_users\n  1586→    stores --> store_settings\n  1587→    stores --> customers\n  1588→    stores --> themes\n  1589→    stores --> pages\n  1590→    stores --> navigation_menus\n  1591→    stores --> search_settings\n  1592→    stores --> shipping_zones\n  1593→    stores --> tax_settings\n  1594→    stores --> discounts\n  1595→    stores --> products\n  1596→    stores --> collections\n  1597→    stores --> app_installations\n  1598→    products --> product_options\n  1599→    products --> product_variants\n  1600→    products --> product_media\n  1601→    collections --> collection_products\n  1602→    products --> collection_products\n  1603→    customers --> customer_addresses\n  1604→    stores --> carts\n  1605→    customers --> carts\n  1606→    navigation_menus --> navigation_items\n  1607→    themes --> theme_files\n  1608→    themes --> theme_settings\n  1609→    stores --> search_queries\n  1610→    shipping_zones --> shipping_rates\n  1611→    app_installations --> oauth_tokens\n  1612→    stores --> webhook_subscriptions\n  1613→    app_installations --> webhook_subscriptions\n  1614→    product_options --> product_option_values\n  1615→    stores --> inventory_items\n  1616→    product_variants --> inventory_items\n  1617→    carts --> cart_lines\n  1618→    product_variants --> cart_lines\n  1619→    stores --> checkouts\n  1620→    carts --> checkouts\n  1621→    customers --> checkouts\n  1622→    stores --> orders\n  1623→    customers --> orders\n  1624→    stores --> analytics_events\n  1625→    customers --> analytics_events\n  1626→    stores --> analytics_daily\n  1627→    webhook_subscriptions --> webhook_deliveries\n  1628→    product_variants --> variant_option_values\n  1629→    product_option_values --> variant_option_values\n  1630→    orders --> order_lines\n  1631→    products --> order_lines\n  1632→    product_variants --> order_lines\n  1633→    orders --> payments\n  1634→    orders --> fulfillments\n  1635→    orders --> refunds\n  1636→    payments --> refunds\n  1637→    fulfillments --> fulfillment_lines\n  1638→    order_lines --> fulfillment_lines\n  1639→```\n  1640→\n  1641→### Batch Details\n  1642→\n  1643→- **Batch 1** (no dependencies): `organizations`, `apps`\n  1644→- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)\n  1645→- **Batch 3** (depends on Batch 2): `store_domains`, `store_users`, `store_settings`, `customers`, `themes`, `pages`, `navigation_menus`, `search_settings`, `shipping_zones`, `tax_settings`, `discounts`, `products`, `collections`, `app_installations`, `oauth_clients`\n  1646→- **Batch 4** (depends on Batch 3): `product_options`, `product_variants`, `product_media`, `collection_products`, `customer_addresses`, `carts`, `navigation_items`, `theme_files`, `theme_settings`, `search_queries`, `shipping_rates`, `oauth_tokens`, `webhook_subscriptions`\n  1647→- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`\n  1648→- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`\n  1649→- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`\n  1650→\n  1651→---\n  1652→\n  1653→## Index Strategy Rationale\n  1654→\n  1655→1. **Tenant-scoped composite indexes** - Most queries filter by `store_id` first, then by status, handle, or date. Composite indexes like (`store_id`, `status`) and (`store_id`, `handle`) serve these access patterns efficiently.\n  1656→\n  1657→2. **Unique constraints on business keys** - Handles, codes, email+store, and external identifiers (SKUs, barcodes, hostnames, OAuth client IDs) have unique indexes to enforce data integrity at the database level.\n  1658→\n  1659→3. **Foreign key indexes** - Every foreign key column has an index to avoid full table scans during JOIN and CASCADE operations. SQLite does not auto-create indexes for foreign keys like some other databases.\n  1660→\n  1661→4. **Composite primary keys for pivot tables** - Tables like `store_users`, `collection_products`, `variant_option_values`, and `analytics_daily` use composite primary keys to naturally enforce uniqueness and serve as their own covering index for the most common query pattern.\n  1662→\n  1663→5. **Date-based indexes for time series** - Tables with temporal queries (orders, analytics, search queries, webhook deliveries) include indexes on date/timestamp columns scoped by store for efficient range scans.\n  1664→\n  1665→6. **Selective indexing** - Not every column is indexed. Columns queried only via their parent's primary key (e.g., `cart_lines` always accessed through `cart_id`) rely on the foreign key index alone. This keeps write overhead and database size manageable.\n  1666→\n  1667→---\n  1668→\n  1669→## Table Count Summary\n  1670→\n  1671→| Epic | Tables | Count |\n  1672→|---|---|---|\n  1673→| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |\n  1674→| 2 - Catalog, Collections, Inventory, Media | products, product_options, product_option_values, product_variants, variant_option_values, inventory_items, collections, collection_products, product_media | 9 |\n  1675→| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |\n  1676→| 4 - Search | search_settings, search_queries | 2 |\n  1677→| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |\n  1678→| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |\n  1679→| 7 - Analytics | analytics_events, analytics_daily | 2 |\n  1680→| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |\n  1681→| **Total application tables** | | **46** |\n  1682→| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |\n  1683→| **Grand total** | | **55** |\n  1684→"}]},"uuid":"7bf165db-00ad-4510-80fc-69b71d99a5a0","timestamp":"2026-02-13T15:23:51.134Z","sourceToolAssistantUUID":"10e540c7-3fc7-4812-bef4-6b8b0fca6b98"}
{"parentUuid":"7bf165db-00ad-4510-80fc-69b71d99a5a0","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01KYWNZB7mrU4c8X6y4LpMSM","toolUseID":"toolu_01KYWNZB7mrU4c8X6y4LpMSM","timestamp":"2026-02-13T15:23:51.348Z","uuid":"0bbd81df-e686-4eeb-aa3d-b9891b3064f9"}
{"parentUuid":"eff265da-84ba-4ffb-820f-1184927f7071","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KYWNZB7mrU4c8X6y4LpMSM","type":"tool_result","content":"     1→# 05 - Business Logic Specification\n     2→\n     3→> E-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12\n     4→\n     5→---\n     6→\n     7→## Conventions\n     8→\n     9→| Convention | Detail |\n    10→|---|---|\n    11→| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |\n    12→| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |\n    13→| Rounding | Round each line independently, then sum (prevents rounding drift) |\n    14→| Database | SQLite - single-writer, WAL mode, no advisory locks needed |\n    15→| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |\n    16→| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |\n    17→\n    18→---\n    19→\n    20→## 1. Multi-Tenancy and Store Isolation\n    21→\n    22→### 1.1 Tenant Resolution Middleware\n    23→\n    24→**Class:** `App\\Http\\Middleware\\ResolveStore`\n    25→\n    26→**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.\n    27→\n    28→#### Storefront Route Resolution (from request hostname)\n    29→\n    30→1. Extract hostname from the incoming HTTP request.\n    31→2. Look up `store_domains` table where `hostname` matches.\n    32→3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.\n    33→4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.\n    34→5. If hostname is not found in `store_domains`: return HTTP 404.\n    35→6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.\n    36→\n    37→#### Admin Route Resolution (from session)\n    38→\n    39→1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).\n    40→2. Verify the authenticated user has a `store_users` record for that store.\n    41→3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.\n    42→\n    43→```mermaid\n    44→flowchart TD\n    45→    A[Incoming Request] --> B{Route type?}\n    46→    B -->|Storefront| C[Extract hostname]\n    47→    C --> D{Hostname in cache?}\n    48→    D -->|Yes| E[Load store from cache]\n    49→    D -->|No| F[Query store_domains table]\n    50→    F --> G{Found?}\n    51→    G -->|No| H[Return 404]\n    52→    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]\n    53→    I --> E\n    54→    E --> J{Store suspended?}\n    55→    J -->|Yes| K[Return 503 maintenance]\n    56→    J -->|No| L[Bind store as singleton, continue]\n    57→\n    58→    B -->|Admin| M[Read store_id from session]\n    59→    M --> N{User has store_users record?}\n    60→    N -->|No| O[Return 403]\n    61→    N -->|Yes| P{Store suspended?}\n    62→    P -->|Yes| Q[Return 403 for mutations]\n    63→    P -->|No| L\n    64→```\n    65→\n    66→### 1.2 Store Scoping\n    67→\n    68→**Trait:** `App\\Models\\Concerns\\BelongsToStore`\n    69→**Global Scope:** `App\\Models\\Scopes\\StoreScope`\n    70→\n    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.\n    72→\n    73→**Behavior:**\n    74→\n    75→- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).\n    76→- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.\n    77→- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.\n    78→\n    79→**Models that must use this trait:**\n    80→products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n    81→\n    82→### 1.3 Role-Based Access Control\n    83→\n    84→**Enum:** `App\\Enums\\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`\n    85→\n    86→**Permission Matrix:**\n    87→\n    88→| Permission | Owner | Admin | Staff | Support |\n    89→|---|---|---|---|---|\n    90→| Manage store settings | Yes | Yes | No | No |\n    91→| Manage staff | Yes | Yes | No | No |\n    92→| Manage products | Yes | Yes | Yes | No |\n    93→| Manage orders | Yes | Yes | Yes | Yes (read-only) |\n    94→| Manage discounts | Yes | Yes | Yes | No |\n    95→| Manage themes | Yes | Yes | No | No |\n    96→| Manage apps | Yes | Yes | No | No |\n    97→| View analytics | Yes | Yes | Yes | No |\n    98→| Manage shipping/tax | Yes | Yes | No | No |\n    99→| Process refunds | Yes | Yes | No | No |\n   100→| Create fulfillments | Yes | Yes | Yes | No |\n   101→\n   102→**Implementation requirements:**\n   103→\n   104→- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).\n   105→- Register gate definitions in a service provider.\n   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.\n   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.\n   108→\n   109→---\n   110→\n   111→## 2. Product Lifecycle\n   112→\n   113→### 2.1 Status Transitions\n   114→\n   115→**Enum:** `App\\Enums\\ProductStatus` - values: `draft`, `active`, `archived`\n   116→\n   117→```mermaid\n   118→stateDiagram-v2\n   119→    [*] --> Draft : Product created\n   120→    Draft --> Active : Publish (requires variant with price > 0, title not empty)\n   121→    Draft --> Archived : Archive\n   122→    Active --> Archived : Archive (hide from storefront)\n   123→    Archived --> Active : Re-publish (same preconditions as Draft->Active)\n   124→    Active --> Draft : Revert (BLOCKED if order_lines exist)\n   125→    Archived --> Draft : Revert (BLOCKED if order_lines exist)\n   126→```\n   127→\n   128→**Transition rules:**\n   129→\n   130→| From | To | Preconditions |\n   131→|---|---|---|\n   132→| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |\n   133→| Draft | Archived | None |\n   134→| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |\n   135→| Archived | Active | Same preconditions as Draft to Active |\n   136→| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n   137→| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |\n   138→\n   139→**Deletion rules:**\n   140→- Only `draft` products with zero `order_lines` references may be hard-deleted.\n   141→- Products with existing order references must be archived, never deleted, to preserve order history integrity.\n   142→\n   143→**Service:** `App\\Services\\ProductService`\n   144→- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.\n   145→- On activation: set `published_at` to current time if not already set.\n   146→- Dispatch `ProductStatusChanged` event after successful transition.\n   147→\n   148→### 2.2 Variant and Option Matrix\n   149→\n   150→**Rules:**\n   151→\n   152→- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.\n   153→- Each option has 1 or more values, stored in `product_option_values`.\n   154→- Variants represent the cartesian product of option values (or a manually curated subset).\n   155→- When a product has no options, a single \"Default\" variant is auto-created with `is_default = 1`.\n   156→\n   157→**Matrix rebuild logic (when options change):**\n   158→\n   159→```\n   160→PROCEDURE rebuildMatrix(product):\n   161→    current_option_values = load all option values grouped by option\n   162→    desired_combos = cartesianProduct(current_option_values)\n   163→    existing_variants = load all variants with their option value sets\n   164→\n   165→    FOR EACH combo IN desired_combos:\n   166→        IF combo matches an existing variant's option value set:\n   167→            SKIP (preserve existing variant - prices, SKUs, inventory intact)\n   168→        ELSE:\n   169→            CREATE new variant with default pricing from first existing variant\n   170→\n   171→    FOR EACH existing_variant NOT matching any desired combo:\n   172→        IF variant has order_line references:\n   173→            ARCHIVE variant (set status = 'archived')\n   174→        ELSE:\n   175→            DELETE variant\n   176→```\n   177→\n   178→**Service:** `App\\Services\\VariantMatrixService`\n   179→\n   180→### 2.3 SKU Uniqueness\n   181→\n   182→- Combination of store_id (derived through the product's store) and SKU must be unique.\n   183→- Enforced at application level before save.\n   184→- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).\n   185→- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.\n   186→\n   187→### 2.4 Handle/Slug Generation\n   188→\n   189→**Helper class:** `App\\Support\\HandleGenerator`\n   190→\n   191→- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).\n   192→- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.\n   193→- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.\n   194→- Merchants can manually edit the handle; on save, re-validate uniqueness.\n   195→- Same logic applies to collections and pages.\n   196→\n   197→---\n   198→\n   199→## 3. Inventory Management\n   200→\n   201→### 3.1 Tracking Model\n   202→\n   203→Each `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).\n   204→\n   205→| Field | Description |\n   206→|---|---|\n   207→| `quantity_on_hand` | Total physical stock count |\n   208→| `quantity_reserved` | Units held for active checkouts or pending orders |\n   209→| **Available** (computed) | `quantity_on_hand - quantity_reserved` |\n   210→\n   211→### 3.2 Inventory Policy\n   212→\n   213→Per-variant policy stored in `inventory_items.policy`:\n   214→\n   215→| Policy | Behavior |\n   216→|---|---|\n   217→| `deny` | Block add-to-cart when available <= 0. Storefront displays \"Out of stock\". |\n   218→| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays \"Available on backorder\". |\n   219→\n   220→### 3.3 Inventory Operations\n   221→\n   222→**Service:** `App\\Services\\InventoryService`\n   223→\n   224→All operations must execute within a database transaction.\n   225→\n   226→```mermaid\n   227→flowchart LR\n   228→    subgraph \"Stock Lifecycle\"\n   229→        A[On Hand] -->|Reserve| B[Reserved]\n   230→        B -->|Commit on payment| C[Deducted]\n   231→        B -->|Release on expiry| A\n   232→        C -->|Restock on refund| A\n   233→    end\n   234→```\n   235→\n   236→**Operation definitions:**\n   237→\n   238→| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |\n   239→|---|---|---|---|---|\n   240→| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |\n   241→| **Release** | Checkout expires or is abandoned | No change | -quantity | None |\n   242→| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |\n   243→| **Restock** | Refund processed with restock flag | +quantity | No change | None |\n   244→\n   245→### 3.4 Concurrency\n   246→\n   247→SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.\n   248→\n   249→---\n   250→\n   251→## 4. Cart Service\n   252→\n   253→### 4.1 Cart Lifecycle\n   254→\n   255→**Service:** `App\\Services\\CartService`\n   256→\n   257→```mermaid\n   258→stateDiagram-v2\n   259→    [*] --> active : Cart created\n   260→    active --> converted : Checkout completed successfully\n   261→    active --> abandoned : Cleanup job (14 days inactive)\n   262→```\n   263→\n   264→**Cart identification:**\n   265→- Guest users: cart ID stored in session cookie.\n   266→- Authenticated customers: cart linked via `customer_id` foreign key.\n   267→\n   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.\n   269→\n   270→**Cart merge on login:**\n   271→\n   272→```\n   273→PROCEDURE mergeGuestCart(guestCart, customerCart):\n   274→    FOR EACH line IN guestCart.lines:\n   275→        existingLine = customerCart.findLineByVariant(line.variant_id)\n   276→        IF existingLine EXISTS:\n   277→            existingLine.quantity = MAX(existingLine.quantity, line.quantity)\n   278→            RECALCULATE existingLine amounts\n   279→        ELSE:\n   280→            MOVE line to customerCart\n   281→    MARK guestCart as abandoned\n   282→    RECALCULATE all line amounts on customerCart\n   283→```\n   284→\n   285→### 4.2 Cart Operations\n   286→\n   287→**Create cart:**\n   288→- Accepts: store, optional customer\n   289→- Sets: store_id, currency from store's default_currency, initial cart_version = 1\n   290→\n   291→**Add line - validations (in order):**\n   292→\n   293→1. Variant exists and belongs to the current store (through its product).\n   294→2. Product has status = `active`.\n   295→3. Variant has status = `active`.\n   296→4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.\n   297→5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.\n   298→6. Set `unit_price_amount` from the variant's `price_amount`.\n   299→7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.\n   300→8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).\n   301→9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.\n   302→10. Increment `cart_version`.\n   303→\n   304→**Update quantity:**\n   305→- Validate new quantity > 0. If 0, remove the line instead.\n   306→- Re-check inventory policy for the new quantity.\n   307→- Recalculate line amounts.\n   308→- Increment `cart_version`.\n   309→\n   310→**Remove line:**\n   311→- Delete the `cart_lines` record.\n   312→- Increment `cart_version`.\n   313→\n   314→### 4.3 Cart Version (Optimistic Concurrency)\n   315→\n   316→Every cart mutation increments `cart_version` by 1.\n   317→\n   318→**For API clients:**\n   319→- Client sends `expected_version` with each mutation request.\n   320→- If cart's current version does not match the expected version, return HTTP 409 Conflict with the current cart state in the response body.\n   321→- This prevents race conditions when multiple browser tabs or devices modify the same cart.\n   322→\n   323→**For Livewire/session-based flows:**\n   324→- Still implemented for consistency, but less critical since requests are serialized per session.\n   325→\n   326→### 4.4 Line Amount Calculation\n   327→\n   328→```\n   329→line_subtotal_amount = unit_price_amount * quantity\n   330→line_discount_amount = 0  (updated by discount engine during checkout)\n   331→line_total_amount    = line_subtotal_amount - line_discount_amount\n   332→```\n   333→\n   334→All values are integers in minor currency units.\n   335→\n   336→### 4.5 Cart Expiration\n   337→\n   338→**Job:** `App\\Jobs\\CleanupAbandonedCarts`\n   339→\n   340→- Schedule: daily\n   341→- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.\n   342→- Marks them as `abandoned`.\n   343→- Releases any reserved inventory associated with active checkouts for these carts.\n   344→\n   345→---\n   346→\n   347→## 5. Pricing Engine\n   348→\n   349→### 5.1 Calculation Order (deterministic pipeline)\n   350→\n   351→**Service:** `App\\Services\\PricingEngine`\n   352→\n   353→The pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.\n   354→\n   355→```mermaid\n   356→flowchart TD\n   357→    A[\"Step 1: Line Subtotals\n   358→    FOR EACH line: subtotal = unit_price * quantity\"] --> B[\"Step 2: Cart Subtotal\n   359→    SUM of all line subtotals\"]\n   360→    B --> C[\"Step 3: Discount\n   361→    Apply discount rules (Section 7)\n   362→    Allocate proportionally across qualifying lines\"]\n   363→    C --> D[\"Step 4: Discounted Subtotal\n   364→    subtotal - total_discount\"]\n   365→    D --> E[\"Step 5: Shipping\n   366→    Based on selected method and rate rules (Section 9)\"]\n   367→    E --> F[\"Step 6: Tax\n   368→    Calculated on discounted line amounts\n   369→    + shipping if taxable (Section 8)\"]\n   370→    F --> G[\"Step 7: Total\n   371→    discounted_subtotal + shipping + tax_total\"]\n   372→```\n   373→\n   374→### 5.2 Output Structure\n   375→\n   376→**Value Object:** `App\\ValueObjects\\PricingResult`\n   377→\n   378→| Field | Type | Description |\n   379→|---|---|---|\n   380→| `subtotal` | integer (cents) | Sum of all line subtotals before discount |\n   381→| `discount` | integer (cents) | Total discount amount |\n   382→| `shipping` | integer (cents) | Shipping cost |\n   383→| `taxLines` | array of TaxLine | Individual tax line items |\n   384→| `taxTotal` | integer (cents) | Sum of all tax line amounts |\n   385→| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |\n   386→| `currency` | string | ISO 4217 currency code |\n   387→\n   388→**Value Object:** `App\\ValueObjects\\TaxLine`\n   389→\n   390→| Field | Type | Description |\n   391→|---|---|---|\n   392→| `name` | string | Tax label (e.g., \"State Tax\") |\n   393→| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |\n   394→| `amount` | integer (cents) | Calculated tax amount |\n   395→\n   396→### 5.3 Rounding Strategy\n   397→\n   398→- Each line's discount allocation, tax, and total are rounded independently.\n   399→- Totals are computed by summing already-rounded line amounts.\n   400→- This prevents accumulation of fractional cents across lines.\n   401→- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).\n   402→\n   403→### 5.4 Determinism and Snapshotting\n   404→\n   405→- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.\n   406→- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.\n   407→- The snapshot structure mirrors the PricingResult value object.\n   408→\n   409→---\n   410→\n   411→## 6. Checkout State Machine\n   412→\n   413→### 6.1 States\n   414→\n   415→**Enum:** `App\\Enums\\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`\n   416→\n   417→```mermaid\n   418→stateDiagram-v2\n   419→    [*] --> started : Checkout created from cart\n   420→    started --> addressed : Address provided\n   421→    addressed --> shipping_selected : Shipping method chosen\n   422→    shipping_selected --> payment_selected : Payment method selected\n   423→    payment_selected --> completed : Payment confirmed\n   424→    payment_selected --> expired : Timeout (24h)\n   425→\n   426→    started --> expired : Timeout (24h)\n   427→    addressed --> expired : Timeout (24h)\n   428→    shipping_selected --> expired : Timeout (24h)\n   429→\n   430→    note right of addressed : Triggers pricing recalculation\n   431→    note right of shipping_selected : Triggers pricing recalculation\n   432→    note right of payment_selected : Inventory reserved\n   433→    note right of completed : Order created, inventory committed\n   434→    note right of expired : Reserved inventory released\n   435→```\n   436→\n   437→### 6.2 Transitions and Validation\n   438→\n   439→**Service:** `App\\Services\\CheckoutService`\n   440→\n   441→#### Transition: started -> addressed\n   442→\n   443→**Method:** `setAddress(checkout, addressData)`\n   444→\n   445→**Required data:**\n   446→- `email` - valid email address\n   447→- `shipping_address` with required fields:\n   448→\n   449→| Field | Required | Type | Notes |\n   450→|---|---|---|---|\n   451→| `first_name` | Yes | string | |\n   452→| `last_name` | Yes | string | |\n   453→| `address1` | Yes | string | |\n   454→| `address2` | No | string | |\n   455→| `company` | No | string | |\n   456→| `city` | Yes | string | |\n   457→| `province` | No | string | |\n   458→| `province_code` | No | string | Used for zone matching |\n   459→| `country` | Yes | string | 2-letter ISO 3166-1 code |\n   460→| `postal_code` | Yes | string | |\n   461→| `phone` | No | string | |\n   462→\n   463→**Actions:**\n   464→1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).\n   465→2. Save email and shipping_address_json on the checkout.\n   466→3. Copy shipping address to billing_address_json by default (can be overridden later).\n   467→4. Recalculate pricing (shipping zones and tax regions may change based on address).\n   468→5. Transition status to `addressed`.\n   469→6. Dispatch `CheckoutAddressed` event.\n   470→\n   471→#### Transition: addressed -> shipping_selected\n   472→\n   473→**Method:** `setShippingMethod(checkout, shippingRateId)`\n   474→\n   475→**Requires:**\n   476→- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.\n   477→- **Skip condition:** If no cart lines have `requires_shipping = true` on their variant, skip this step. Set status to `shipping_selected` with shipping_method_id = null and shipping_amount = 0.\n   478→\n   479→**Actions:**\n   480→1. Resolve available shipping zones for the address (see Section 9).\n   481→2. Verify the selected rate belongs to an applicable zone.\n   482→3. Save shipping_method_id on the checkout.\n   483→4. Recalculate pricing with the selected shipping rate.\n   484→5. Transition status to `shipping_selected`.\n   485→6. Dispatch `CheckoutShippingSelected` event.\n   486→\n   487→#### Transition: shipping_selected -> payment_selected\n   488→\n   489→**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method\n   490→\n   491→**Requires:**\n   492→- All previous data is valid (address, shipping method).\n   493→- Totals have been calculated and stored.\n   494→- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.\n   495→\n   496→**Actions:**\n   497→1. Store `payment_method` on the checkout record.\n   498→2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).\n   499→3. Set `expires_at` on the checkout to current time + 24 hours.\n   500→4. Transition status to `payment_selected`.\n   501→\n   502→#### Transition: payment_selected -> completed\n   503→\n   504→**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order\n   505→\n   506→**Triggered by:** Customer submitting the payment form (onsite, no external redirect).\n   507→\n   508→**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., double-click), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.\n   509→\n   510→**Actions (all within a single database transaction):**\n   511→\n   512→```\n   513→PROCEDURE completeCheckout(checkout, paymentMethodData):\n   514→    BEGIN TRANSACTION\n   515→\n   516→    1. IF order already exists for this checkout:\n   517→           RETURN existing order (idempotent)\n   518→\n   519→    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)\n   520→       IF result.success = false:\n   521→           Release reserved inventory\n   522→           THROW PaymentFailedException(result.errorCode)\n   523→\n   524→    3. Generate next sequential order number for this store\n   525→\n   526→    4. DETERMINE order statuses based on payment method:\n   527→       IF checkout.payment_method IN ('credit_card', 'paypal'):\n   528→           orderStatus = 'paid'\n   529→           financialStatus = 'paid'\n   530→           paymentStatus = 'captured'\n   531→           inventoryAction = 'commit'\n   532→       ELSE IF checkout.payment_method = 'bank_transfer':\n   533→           orderStatus = 'pending'\n   534→           financialStatus = 'pending'\n   535→           paymentStatus = 'pending'\n   536→           inventoryAction = 'keep_reserved'\n   537→\n   538→    5. CREATE order record with:\n   539→       - All checkout data (addresses, email, totals snapshot)\n   540→       - payment_method = checkout.payment_method\n   541→       - status = orderStatus, financial_status = financialStatus\n   542→       - fulfillment_status = 'unfulfilled'\n   543→       - placed_at = now\n   544→\n   545→    6. FOR EACH cart line:\n   546→       CREATE order_line with snapshot data:\n   547→       - title_snapshot = product title + variant option labels\n   548→       - sku_snapshot = variant SKU at time of purchase\n   549→       - unit_price_amount, quantity, total_amount\n   550→       - tax_lines_json, discount_allocations_json\n   551→\n   552→    7. CREATE payment record:\n   553→       - Link to order, provider = 'mock', method = checkout.payment_method\n   554→       - provider_payment_id = mock reference ID\n   555→       - status = paymentStatus, amount = order total\n   556→\n   557→    8. IF inventoryAction = 'commit':\n   558→       FOR EACH cart line:\n   559→           CALL InventoryService.commit(variant, quantity)\n   560→       // Note: for bank_transfer, inventory stays reserved until admin confirms payment\n   561→\n   562→    9. IF checkout has a discount code:\n   563→       INCREMENT discount's usage_count by 1\n   564→\n   565→    10. SET cart status = 'converted'\n   566→\n   567→    11. SET checkout status = 'completed'\n   568→\n   569→    12. IF inventoryAction = 'commit' AND all order lines are digital:\n   570→        Auto-create fulfillment (see Section 11.7)\n   571→\n   572→    13. DISPATCH OrderCreated event\n   573→\n   574→    COMMIT TRANSACTION\n   575→    RETURN order\n   576→```\n   577→\n   578→#### Transition: Any active state -> expired\n   579→\n   580→**Triggered by:** `App\\Jobs\\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).\n   581→\n   582→**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.\n   583→\n   584→**Actions:**\n   585→1. If inventory was reserved (status was `payment_selected`): release all reserved inventory via InventoryService.release.\n   586→2. Transition status to `expired`.\n   587→3. Dispatch `CheckoutExpired` event.\n   588→\n   589→### 6.3 Events\n   590→\n   591→| Event | Dispatched When |\n   592→|---|---|\n   593→| `CheckoutAddressed` | Address saved on checkout |\n   594→| `CheckoutShippingSelected` | Shipping method selected |\n   595→| `CheckoutCompleted` | Payment confirmed and order created |\n   596→| `CheckoutExpired` | Checkout expired due to inactivity |\n   597→\n   598→---\n   599→\n   600→## 7. Discount Engine\n   601→\n   602→### 7.1 Discount Types\n   603→\n   604→**Enum:** `App\\Enums\\DiscountType` - values: `code`, `automatic`\n   605→\n   606→| Type | Behavior |\n   607→|---|---|\n   608→| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |\n   609→| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |\n   610→\n   611→**Stacking rules:**\n   612→- Only one code discount per checkout.\n   613→- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).\n   614→\n   615→### Discount Status Lifecycle\n   616→\n   617→| Status | Description | Transition |\n   618→|--------|-------------|------------|\n   619→| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |\n   620→| `active` | Live and applicable to carts (subject to date range and usage limits). | Automatic transition when `starts_at` is reached (if set). Admin can manually activate. |\n   621→| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |\n   622→| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |\n   623→\n   624→**Allowed transitions:**\n   625→- `draft` -> `active`\n   626→- `active` -> `disabled`\n   627→- `active` -> `expired` (automatic)\n   628→- `disabled` -> `active`\n   629→\n   630→### 7.2 Value Types\n   631→\n   632→**Enum:** `App\\Enums\\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`\n   633→\n   634→| Value Type | Calculation | Notes |\n   635→|---|---|---|\n   636→| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |\n   637→| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |\n   638→| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |\n   639→\n   640→### 7.3 Rules\n   641→\n   642→Rules are stored in `discounts.rules_json` with the following structure:\n   643→\n   644→| Rule Key | Type | Description |\n   645→|---|---|---|\n   646→| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |\n   647→| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |\n   648→| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |\n   649→| `customer_eligibility` | string | `\"all\"` for all customers. Future expansion: `\"returning\"`, `\"specific\"`. |\n   650→\n   651→**Applicability logic:** When both `applicable_product_ids` and `applicable_collection_ids` are set, a product qualifies if it matches **either** condition (union, not intersection). When both are null or empty, the discount applies to the entire order.\n   652→\n   653→### 7.4 Validation Flow\n   654→\n   655→**Service:** `App\\Services\\DiscountService`\n   656→\n   657→**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object\n   658→\n   659→```mermaid\n   660→flowchart TD\n   661→    A[Input: discount code + cart] --> B{Code exists in store?\n   662→    Case-insensitive lookup}\n   663→    B -->|No| C[\"Error: discount_not_found\"]\n   664→    B -->|Yes| D{Status = active?}\n   665→    D -->|No| E[\"Error: discount_expired\"]\n   666→    D -->|Yes| F{starts_at <= now?}\n   667→    F -->|No| G[\"Error: discount_not_yet_active\"]\n   668→    F -->|Yes| H{ends_at is null OR ends_at >= now?}\n   669→    H -->|No| I[\"Error: discount_expired\"]\n   670→    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}\n   671→    J -->|Yes| K[\"Error: discount_usage_limit_reached\"]\n   672→    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}\n   673→    L -->|Yes| M[\"Error: discount_min_purchase_not_met\"]\n   674→    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}\n   675→    N -->|Yes| O[\"Error: discount_not_applicable\"]\n   676→    N -->|No| P[\"Valid - return discount\"]\n   677→```\n   678→\n   679→### 7.5 Error Codes\n   680→\n   681→| Code | Description |\n   682→|---|---|\n   683→| `discount_not_found` | Code does not exist in this store |\n   684→| `discount_expired` | Past `ends_at` date or status is not active |\n   685→| `discount_not_yet_active` | Before `starts_at` date |\n   686→| `discount_usage_limit_reached` | `usage_count >= usage_limit` |\n   687→| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |\n   688→| `discount_not_applicable` | No qualifying products in cart |\n   689→\n   690→### 7.6 Discount Allocation\n   691→\n   692→For `percent` and `fixed` discounts with applicable product restrictions:\n   693→\n   694→```\n   695→PROCEDURE allocateDiscount(discount, cartLines):\n   696→    qualifyingLines = FILTER cartLines where product matches applicable_product_ids\n   697→                      OR product belongs to applicable_collection_ids\n   698→    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)\n   699→\n   700→    IF discount.value_type = 'percent':\n   701→        totalDiscount = qualifyingSubtotal * discount.value_amount / 100\n   702→    ELSE IF discount.value_type = 'fixed':\n   703→        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)\n   704→\n   705→    remainingDiscount = totalDiscount\n   706→\n   707→    FOR EACH qualifyingLine (except last):\n   708→        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)\n   709→        remainingDiscount -= lineDiscount\n   710→        SET line.line_discount_amount = lineDiscount\n   711→\n   712→    // Last line gets the remainder (largest-remainder method)\n   713→    SET lastLine.line_discount_amount = remainingDiscount\n   714→\n   715→    RECALCULATE line_total_amount for each affected line\n   716→```\n   717→\n   718→**Storage of allocations:**\n   719→- During checkout: update `cart_lines.line_discount_amount`.\n   720→- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.\n   721→\n   722→### 7.7 Usage Tracking\n   723→\n   724→When an order is successfully created with a discount code:\n   725→- Increment `discounts.usage_count` by 1.\n   726→- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).\n   727→\n   728→---\n   729→\n   730→## 8. Tax Calculation\n   731→\n   732→### 8.1 Tax Provider Architecture\n   733→\n   734→**Interface:** `App\\Contracts\\TaxProvider`\n   735→\n   736→- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.\n   737→- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.\n   738→- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.\n   739→\n   740→**Service:** `App\\Services\\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.\n   741→\n   742→**Implementations:**\n   743→- `App\\Services\\Tax\\ManualTaxProvider` - default, uses manually configured rates\n   744→- `App\\Services\\Tax\\StripeTaxProvider` - stub for Stripe Tax API integration\n   745→\n   746→### 8.2 Manual Tax Mode\n   747→\n   748→**Rate resolution:**\n   749→1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).\n   750→2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.\n   751→3. Rate stored as integer basis points (e.g., 1900 = 19.00%).\n   752→\n   753→**Calculation per line:**\n   754→\n   755→```\n   756→line_tax = ROUND(line_total_after_discount * rate / 10000)\n   757→```\n   758→\n   759→Round each line's tax independently, then sum for the total tax amount.\n   760→\n   761→### 8.3 Prices Include Tax\n   762→\n   763→Controlled by `tax_settings.prices_include_tax` (boolean).\n   764→\n   765→#### Tax-inclusive pricing (prices_include_tax = true)\n   766→\n   767→- Displayed prices on the storefront are gross (include tax).\n   768→- Subtotal shown to customer is gross.\n   769→- Tax is \"extracted\" from the gross amount:\n   770→\n   771→```\n   772→net_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)\n   773→tax = gross_amount - net_amount\n   774→```\n   775→\n   776→> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.\n   777→\n   778→**Example:** Price = 1190 cents, rate = 1900 (19%).\n   779→- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000\n   780→- tax = 1190 - 1000 = 190\n   781→\n   782→#### Tax-exclusive pricing (prices_include_tax = false)\n   783→\n   784→- Displayed prices are net (excluding tax).\n   785→- Tax added on top:\n   786→\n   787→```\n   788→tax = ROUND(net_amount * rate / 10000)\n   789→total = net_amount + tax\n   790→```\n   791→\n   792→**Example:** Price = 1000 cents, rate = 1900.\n   793→- tax = ROUND(1000 * 1900 / 10000) = 190\n   794→- total = 1190\n   795→\n   796→### 8.4 Stripe Tax Provider (Stub)\n   797→\n   798→**Implementation:** `App\\Services\\Tax\\StripeTaxProvider`\n   799→\n   800→- Sends line items and shipping address to Stripe Tax API.\n   801→- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.\n   802→- Fallback behavior (configurable per store in `tax_settings.config_json`):\n   803→\n   804→| Fallback Setting | Behavior |\n   805→|---|---|\n   806→| `\"block\"` | Block checkout progression if Stripe Tax API errors |\n   807→| `\"allow\"` | Allow checkout to proceed without tax (log a warning) |\n   808→\n   809→### 8.5 Discount-Before-Tax Semantics\n   810→\n   811→Discounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.\n   812→\n   813→For tax-inclusive pricing (`prices_include_tax = true`): The discount is subtracted from the gross (tax-inclusive) subtotal. Tax is then extracted from the post-discount gross amount: `tax = post_discount_gross * rate / (1 + rate)`. This means the tax portion decreases proportionally when a discount is applied.\n   814→\n   815→**Calculation order within the pricing pipeline:**\n   816→1. Calculate line subtotals.\n   817→2. Apply discount allocations to lines.\n   818→3. Calculate tax on discounted line amounts.\n   819→4. Sum everything for the total.\n   820→\n   821→---\n   822→\n   823→## 9. Shipping Calculation\n   824→\n   825→### 9.1 Zone Matching\n   826→\n   827→**Service:** `App\\Services\\ShippingCalculator`\n   828→\n   829→**Zone matching algorithm:**\n   830→\n   831→```\n   832→PROCEDURE getMatchingZone(address, store):\n   833→    zones = load all shipping zones for store\n   834→\n   835→    bestMatch = null\n   836→    bestSpecificity = -1\n   837→\n   838→    FOR EACH zone IN zones:\n   839→        countryMatch = address.country_code IN zone.countries_json\n   840→        regionMatch = address.province_code IN zone.regions_json\n   841→\n   842→        IF countryMatch AND regionMatch:\n   843→            specificity = 2  // Most specific\n   844→        ELSE IF countryMatch:\n   845→            specificity = 1  // Country-only match\n   846→        ELSE:\n   847→            CONTINUE  // No match\n   848→\n   849→        IF specificity > bestSpecificity:\n   850→            bestMatch = zone\n   851→            bestSpecificity = specificity\n   852→        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:\n   853→            bestMatch = zone  // Tie-break: lowest ID wins\n   854→\n   855→    RETURN bestMatch  // null if no zone matches\n   856→```\n   857→\n   858→**When no zone matches:** The address is unserviceable. Block checkout progression with error \"Cannot ship to this address\".\n   859→\n   860→### 9.2 Rate Types\n   861→\n   862→For the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:\n   863→\n   864→#### Flat rate\n   865→\n   866→Config structure: `{\"amount\": 500}`\n   867→\n   868→- Cost = fixed amount regardless of cart contents.\n   869→\n   870→#### Weight-based rate\n   871→\n   872→Config structure: `{\"ranges\": [{\"min_g\": 0, \"max_g\": 1000, \"amount\": 500}, {\"min_g\": 1001, \"max_g\": 5000, \"amount\": 1000}]}`\n   873→\n   874→```\n   875→PROCEDURE calculateWeightRate(config, cart):\n   876→    totalWeight = 0\n   877→    FOR EACH line IN cart.lines:\n   878→        IF line.variant.requires_shipping = true:\n   879→            totalWeight += line.variant.weight_g * line.quantity\n   880→\n   881→    FOR EACH range IN config.ranges:\n   882→        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:\n   883→            RETURN range.amount\n   884→\n   885→    RETURN null  // Rate not available for this weight\n   886→```\n   887→\n   888→#### Price-based rate\n   889→\n   890→Config structure: `{\"ranges\": [{\"min_amount\": 0, \"max_amount\": 5000, \"amount\": 500}, {\"min_amount\": 5001, \"amount\": 0}]}`\n   891→\n   892→```\n   893→PROCEDURE calculatePriceRate(config, cartSubtotal):\n   894→    FOR EACH range IN config.ranges:\n   895→        IF range.min_amount <= cartSubtotal:\n   896→            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:\n   897→                RETURN range.amount\n   898→\n   899→    RETURN null  // Rate not available for this subtotal\n   900→```\n   901→\n   902→A range without `max_amount` matches all subtotals above `min_amount` (useful for \"free shipping over X\" rules).\n   903→\n   904→#### Carrier-calculated rate (stub)\n   905→\n   906→Config structure: `{\"carrier\": \"ups\", \"service\": \"ground\"}`\n   907→\n   908→- Placeholder for future carrier API integration.\n   909→- Returns a hardcoded rate or error until implemented.\n   910→\n   911→### 9.3 Requires Shipping Check\n   912→\n   913→- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.\n   914→- shipping_amount = 0 in the pricing result.\n   915→- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).\n   916→\n   917→**Mixed carts** (some physical, some digital):\n   918→- Shipping is calculated only for physical items' weight.\n   919→- Digital items are excluded from weight calculations.\n   920→\n   921→### 9.4 Free Shipping Discount Override\n   922→\n   923→If a validated discount has `value_type = 'free_shipping'`:\n   924→- Override the calculated shipping amount to 0.\n   925→- The shipping method is still selected (for fulfillment routing), but the customer is not charged.\n   926→\n   927→---\n   928→\n   929→## 10. Payment Processing (Mock PSP)\n   930→\n   931→### 10.1 Mock Payment Provider Architecture\n   932→\n   933→The platform uses a **Mock Payment Service Provider (PSP)** instead of real payment gateways. All payment processing happens in-process with no external API calls, no webhooks, and no redirects. This enables a fully functional checkout flow for development, testing, and demonstration purposes.\n   934→\n   935→**Interface:** `App\\Contracts\\PaymentProvider`\n   936→\n   937→Must implement:\n   938→- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)\n   939→- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult\n   940→\n   941→**Service:** `App\\Services\\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.\n   942→\n   943→**Implementation:** `App\\Services\\Payment\\MockPaymentProvider`\n   944→\n   945→### 10.2 Supported Payment Methods\n   946→\n   947→**Enum:** `App\\Enums\\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)\n   948→\n   949→| Method | Behavior | Capture Timing |\n   950→|---|---|---|\n   951→| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |\n   952→| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |\n   953→| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |\n   954→\n   955→### 10.3 Magic Card Numbers (Credit Card Testing)\n   956→\n   957→When payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:\n   958→\n   959→| Card Number | Outcome | PaymentResult |\n   960→|---|---|---|\n   961→| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |\n   962→| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |\n   963→| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |\n   964→| Any other valid-looking number | Success | `success = true`, status = `captured` |\n   965→\n   966→The mock provider does **not** validate card expiry, CVC, or cardholder name. These fields exist in the UI for realism but are ignored by the backend.\n   967→\n   968→### 10.4 Mock Payment Flow\n   969→\n   970→```mermaid\n   971→sequenceDiagram\n   972→    participant Customer\n   973→    participant Storefront\n   974→    participant Backend\n   975→    participant MockPSP\n   976→\n   977→    Customer->>Storefront: Select payment method + enter details\n   978→    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)\n   979→    Backend->>Backend: Reserve inventory (if not already reserved)\n   980→    Backend->>MockPSP: charge(checkout, paymentMethodData)\n   981→\n   982→    alt Credit Card / PayPal (instant capture)\n   983→        MockPSP-->>Backend: PaymentResult (success/failure)\n   984→        alt Success\n   985→            Backend->>Backend: Create order (financial_status = paid)\n   986→            Backend->>Backend: Commit inventory, mark cart converted\n   987→            Backend-->>Storefront: 200 OK (order confirmation)\n   988→        else Failure (decline / insufficient funds)\n   989→            Backend->>Backend: Release reserved inventory\n   990→            Backend-->>Storefront: 422 (error code + message)\n   991→        end\n   992→    else Bank Transfer (deferred)\n   993→        MockPSP-->>Backend: PaymentResult (success = true, status = pending)\n   994→        Backend->>Backend: Create order (financial_status = pending)\n   995→        Backend->>Backend: Inventory remains reserved (not committed)\n   996→        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)\n   997→    end\n   998→```\n   999→\n  1000→### 10.5 Payment State Machine\n  1001→\n  1002→**Enum:** `App\\Enums\\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`\n  1003→\n  1004→```mermaid\n  1005→stateDiagram-v2\n  1006→    [*] --> captured : Credit card / PayPal (success)\n  1007→    [*] --> failed : Credit card (decline)\n  1008→    [*] --> pending : Bank transfer (awaiting payment)\n  1009→    pending --> captured : Admin confirms payment received\n  1010→    pending --> failed : Auto-cancel job (unpaid timeout)\n  1011→    captured --> refunded : Full refund processed\n  1012→```\n  1013→\n  1014→### 10.6 Bank Transfer Instructions\n  1015→\n  1016→When `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:\n  1017→\n  1018→| Field | Value |\n  1019→|---|---|\n  1020→| Bank name | Mock Bank AG |\n  1021→| IBAN | DE89 3704 0044 0532 0130 00 |\n  1022→| BIC | COBADEFFXXX |\n  1023→| Reference | Order number (e.g., #1001) |\n  1024→| Amount | Order total formatted in EUR |\n  1025→\n  1026→These are static mock values. No real bank interaction occurs.\n  1027→\n  1028→### 10.7 Admin: Confirm Payment (Bank Transfer)\n  1029→\n  1030→Admin users can confirm that a bank transfer payment has been received via a \"Confirm Payment\" button on the order detail page (visible only when `payment_method = bank_transfer` AND `financial_status = pending`).\n  1031→\n  1032→**Action flow:**\n  1033→1. Admin clicks \"Confirm Payment\" button.\n  1034→2. Backend updates `payments.status` from `pending` to `captured`.\n  1035→3. Backend updates `orders.financial_status` from `pending` to `paid`.\n  1036→4. Backend updates `orders.status` from `pending` to `paid`.\n  1037→5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).\n  1038→6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).\n  1039→7. Dispatch `OrderPaid` event.\n  1040→\n  1041→### 10.8 Auto-Cancel Unpaid Bank Transfer Orders\n  1042→\n  1043→**Job:** `App\\Jobs\\CancelUnpaidBankTransferOrders`\n  1044→\n  1045→- Schedule: daily\n  1046→- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.\n  1047→- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.\n  1048→\n  1049→**Cancel action:**\n  1050→1. Release reserved inventory via `InventoryService.release()`.\n  1051→2. Update `orders.financial_status` to `voided`.\n  1052→3. Update `orders.status` to `cancelled`.\n  1053→4. Update `payments.status` to `failed`.\n  1054→5. Dispatch `OrderCancelled` event.\n  1055→\n  1056→---\n  1057→\n  1058→## 11. Order Management\n  1059→\n  1060→### 11.1 Order Creation (from Completed Checkout)\n  1061→\n  1062→See Section 6.2 \"Transition: payment_selected -> completed\" for the full procedure. The order creation is the central action within the completeCheckout transaction.\n  1063→\n  1064→**Key requirements for the order record:**\n  1065→- Snapshot all checkout data (addresses, email, totals) at time of order creation.\n  1066→- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.\n  1067→- The mock payment response is encrypted before storage.\n  1068→- The entire operation must be atomic (single database transaction).\n  1069→- The `payment_method` from the checkout is copied to the order record.\n  1070→\n  1071→**Payment-method-specific order creation:**\n  1072→\n  1073→| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |\n  1074→|---|---|---|---|---|\n  1075→| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n  1076→| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |\n  1077→| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |\n  1078→\n  1079→### 11.2 Order Numbering\n  1080→\n  1081→- Sequential per store, starting at 1001.\n  1082→- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.\n  1083→- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.\n  1084→- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.\n  1085→\n  1086→### 11.3 Order Statuses\n  1087→\n  1088→Three independent status dimensions tracked on each order:\n  1089→\n  1090→**Order status** (`orders.status`):\n  1091→\n  1092→| Status | Description |\n  1093→|---|---|\n  1094→| `pending` | Order created, awaiting processing |\n  1095→| `paid` | Payment confirmed |\n  1096→| `fulfilled` | All items shipped |\n  1097→| `cancelled` | Order cancelled before fulfillment |\n  1098→| `refunded` | Fully refunded |\n  1099→\n  1100→**Financial status** (`orders.financial_status`):\n  1101→\n  1102→| Status | Description |\n  1103→|---|---|\n  1104→| `pending` | Payment not yet confirmed |\n  1105→| `authorized` | Payment authorized but not captured |\n  1106→| `paid` | Payment captured successfully |\n  1107→| `partially_refunded` | Some items or partial amount refunded |\n  1108→| `refunded` | Fully refunded |\n  1109→| `voided` | Authorization voided (no capture occurred) |\n  1110→\n  1111→**Fulfillment status** (`orders.fulfillment_status`):\n  1112→\n  1113→| Status | Description |\n  1114→|---|---|\n  1115→| `unfulfilled` | No items have been shipped |\n  1116→| `partial` | Some items shipped, others pending |\n  1117→| `fulfilled` | All items shipped |\n  1118→\n  1119→### 11.4 Refund Processing\n  1120→\n  1121→**Service:** `App\\Services\\RefundService`\n  1122→\n  1123→**Input (RefundRequest):**\n  1124→\n  1125→| Field | Type | Required | Description |\n  1126→|---|---|---|---|\n  1127→| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |\n  1128→| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |\n  1129→| `reason` | string | No | Text reason for the refund. |\n  1130→| `restock` | boolean | No | Whether to restock inventory for refunded items. |\n  1131→\n  1132→**Process:**\n  1133→\n  1134→```\n  1135→PROCEDURE processRefund(order, request):\n  1136→    1. Calculate remaining refundable amount:\n  1137→       refundable = order.total_amount - SUM(existing refunds for this order)\n  1138→\n  1139→    2. IF request.amount is set:\n  1140→           VALIDATE request.amount <= refundable\n  1141→       ELSE IF request.lines is set:\n  1142→           CALCULATE amount from specified lines and quantities\n  1143→       ELSE:\n  1144→           amount = refundable (full refund)\n  1145→\n  1146→    3. CALL PaymentProvider.refund(order's payment, amount)\n  1147→\n  1148→    4. CREATE Refund record with status = 'pending'\n  1149→\n  1150→    5. On provider confirmation:\n  1151→       UPDATE refund status = 'processed'\n  1152→\n  1153→    6. Calculate total refunded for this order:\n  1154→       IF total refunded = order.total_amount:\n  1155→           SET order.financial_status = 'refunded'\n  1156→           SET order.status = 'refunded'\n  1157→       ELSE:\n  1158→           SET order.financial_status = 'partially_refunded'\n  1159→\n  1160→    7. IF request.restock = true:\n  1161→       FOR EACH refunded line and quantity:\n  1162→           CALL InventoryService.restock(variant, quantity)\n  1163→\n  1164→    8. DISPATCH OrderRefunded event\n  1165→```\n  1166→\n  1167→### 11.5 Fulfillment\n  1168→\n  1169→**Service:** `App\\Services\\FulfillmentService`\n  1170→\n  1171→#### Fulfillment Guard\n  1172→\n  1173→**CRITICAL:** A fulfillment cannot be created unless the order's `financial_status` is `paid` or `partially_refunded`. This prevents shipping goods before payment is confirmed.\n  1174→\n  1175→| `financial_status` | Fulfillment Allowed? | Reason |\n  1176→|---|---|---|\n  1177→| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |\n  1178→| `authorized` | **No** | Payment authorized but not captured |\n  1179→| `paid` | **Yes** | Payment confirmed |\n  1180→| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |\n  1181→| `refunded` | **No** | Fully refunded, nothing to ship |\n  1182→| `voided` | **No** | Payment voided / cancelled |\n  1183→\n  1184→When fulfillment is blocked by the guard, the admin UI shows an info callout: \"Fulfillment cannot be created until payment is confirmed.\" The \"Create fulfillment\" button is disabled.\n  1185→\n  1186→#### Creating a Fulfillment\n  1187→\n  1188→Input:\n  1189→- `lines` - map of order_line_id to quantity, specifying which items are in this shipment\n  1190→- `trackingData` - tracking_company, tracking_number, tracking_url\n  1191→\n  1192→**Process:**\n  1193→\n  1194→```\n  1195→PROCEDURE createFulfillment(order, lines, trackingData):\n  1196→    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')\n  1197→       IF NOT: throw FulfillmentGuardException\n  1198→\n  1199→    2. FOR EACH line in request:\n  1200→       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)\n  1201→       unfulfilled = order_line.quantity - fulfilledSoFar\n  1202→       VALIDATE requested quantity <= unfulfilled\n  1203→\n  1204→    3. CREATE Fulfillment record with status = 'pending' and tracking data\n  1205→\n  1206→    4. CREATE fulfillment_lines for each order line and quantity\n  1207→\n  1208→    5. Determine new fulfillment status:\n  1209→       FOR EACH order_line in order:\n  1210→           totalFulfilled = SUM(all fulfillment_lines for this order_line)\n  1211→           IF totalFulfilled < order_line.quantity:\n  1212→               allFulfilled = false\n  1213→\n  1214→       IF allFulfilled:\n  1215→           SET order.fulfillment_status = 'fulfilled'\n  1216→           SET order.status = 'fulfilled'\n  1217→       ELSE:\n  1218→           SET order.fulfillment_status = 'partial'\n  1219→\n  1220→    6. DISPATCH FulfillmentCreated event\n  1221→```\n  1222→\n  1223→#### Fulfillment Status Transitions (Admin Button Actions)\n  1224→\n  1225→Fulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.\n  1226→\n  1227→```mermaid\n  1228→stateDiagram-v2\n  1229→    [*] --> pending : Fulfillment created (admin clicks \"Create fulfillment\")\n  1230→    pending --> shipped : Admin clicks \"Mark as shipped\"\n  1231→    shipped --> delivered : Admin clicks \"Mark as delivered\"\n  1232→\n  1233→    note right of pending : Fulfillment record created with tracking data\n  1234→    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer\n  1235→    note right of delivered : Sets delivered_at timestamp\n  1236→```\n  1237→\n  1238→**Admin buttons on each fulfillment card:**\n  1239→\n  1240→| Button | Shown When | Action |\n  1241→|---|---|---|\n  1242→| \"Mark as shipped\" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |\n  1243→| \"Mark as delivered\" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |\n  1244→\n  1245→When transitioning to `shipped`:\n  1246→- Set `shipped_at` timestamp.\n  1247→- Dispatch `FulfillmentShipped` event.\n  1248→- Send email notification to customer with tracking information.\n  1249→\n  1250→When transitioning to `delivered`:\n  1251→- Dispatch `FulfillmentDelivered` event.\n  1252→\n  1253→### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method\n  1254→\n  1255→The fulfillment guard interacts with inventory differently depending on the payment method:\n  1256→\n  1257→| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |\n  1258→|---|---|---|---|\n  1259→| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |\n  1260→| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |\n  1261→\n  1262→For bank transfer orders, inventory is held in a reserved state until the admin confirms payment. If the order is auto-cancelled (see Section 10.8), the reserved inventory is released back.\n  1263→\n  1264→### 11.7 Auto-Fulfillment for Digital Products\n  1265→\n  1266→When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):\n  1267→\n  1268→1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).\n  1269→2. If yes: automatically create a fulfillment record covering all order lines:\n  1270→   - `status = 'delivered'` (skip pending and shipped)\n  1271→   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`\n  1272→   - `shipped_at = now`\n  1273→   - Set `order.fulfillment_status = 'fulfilled'`\n  1274→   - Set `order.status = 'fulfilled'`\n  1275→3. If the order contains a mix of physical and digital products: do **not** auto-fulfill. The admin must create fulfillments manually (they can fulfill digital items immediately and physical items when shipped).\n  1276→\n  1277→This ensures digital product orders are immediately marked as complete after payment.\n  1278→\n  1279→### 11.6 Order Export\n  1280→\n  1281→`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.\n  1282→\n  1283→**CSV columns:** `order_number`, `created_at`, `status`, `financial_status`, `fulfillment_status`, `customer_email`, `customer_name`, `subtotal_amount`, `discount_amount`, `shipping_amount`, `tax_amount`, `total_amount`, `currency`, `shipping_method`, `tracking_number`\n  1284→\n  1285→All monetary values are exported in minor units (cents). Dates use ISO-8601 format. The response uses `Content-Type: text/csv` with `Content-Disposition: attachment; filename=\"orders-{date}.csv\"`.\n  1286→\n  1287→---\n  1288→\n  1289→## 12. Customer Accounts\n  1290→\n  1291→### 12.1 Registration\n  1292→\n  1293→**Service:** `App\\Services\\CustomerService`\n  1294→\n  1295→**Required fields:**\n  1296→\n  1297→| Field | Validation |\n  1298→|---|---|\n  1299→| `name` | Required, string |\n  1300→| `email` | Required, valid email, unique per store |\n  1301→| `password` | Required, minimum 8 characters |\n  1302→| `marketing_opt_in` | Optional, boolean, default false |\n  1303→\n  1304→- Email uniqueness enforced by the database unique index on `(store_id, email)`.\n  1305→- Password hashed using Laravel's Hash facade (bcrypt by default).\n  1306→\n  1307→### 12.2 Authentication\n  1308→\n  1309→**Separate auth guard:** `customer`\n  1310→\n  1311→Requirements:\n  1312→- Configure a dedicated `customer` guard in auth config, using session driver.\n  1313→- Configure a dedicated `customers` provider using the Customer model.\n  1314→- Login: email + password, scoped to the current store.\n  1315→- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.\n  1316→\n  1317→**Store isolation:** Customer accounts are per-store. The same email address can exist in different stores as separate accounts. Authentication always scopes the query to the current store.\n  1318→\n  1319→### 12.3 Data Access\n  1320→\n  1321→- Customers can only view their own orders (scoped by customer_id match).\n  1322→- Customers can only manage their own addresses (scoped by customer_id match).\n  1323→- All customer queries include the StoreScope global scope for tenant isolation.\n  1324→\n  1325→### 12.4 Guest Cart to Customer Linking\n  1326→\n  1327→```\n  1328→PROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):\n  1329→    existingCustomer = find customer by email in store\n  1330→    IF existingCustomer:\n  1331→        LINK order to existingCustomer\n  1332→    ELSE:\n  1333→        CREATE new customer with:\n  1334→            - email = checkoutEmail\n  1335→            - password_hash = null (guest customer)\n  1336→            - store_id = storeId\n  1337→        LINK order to new customer\n  1338→\n  1339→    // Guest customer can later \"claim\" their account\n  1340→    // by setting a password via registration or account-claim flow\n  1341→```\n  1342→\n  1343→---\n  1344→\n  1345→## 13. Webhook Delivery System\n  1346→\n  1347→### 13.1 Subscription Model\n  1348→\n  1349→Stores (or app installations) subscribe to event types via `webhook_subscriptions`.\n  1350→\n  1351→**Supported event types:**\n  1352→\n  1353→| Event Type | Triggered By |\n  1354→|---|---|\n  1355→| `order.created` | OrderCreated event |\n  1356→| `order.paid` | Payment captured successfully |\n  1357→| `order.fulfilled` | All order lines fulfilled |\n  1358→| `order.refunded` | Refund processed |\n  1359→| `product.created` | New product saved |\n  1360→| `product.updated` | Product modified |\n  1361→| `product.deleted` | Product deleted or archived |\n  1362→| `checkout.completed` | Checkout successfully completed |\n  1363→\n  1364→### 13.2 Delivery Process\n  1365→\n  1366→**Listener:** `App\\Listeners\\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.\n  1367→\n  1368→**Job:** `App\\Jobs\\DeliverWebhook`\n  1369→\n  1370→```mermaid\n  1371→sequenceDiagram\n  1372→    participant DomainEvent\n  1373→    participant Listener\n  1374→    participant Queue\n  1375→    participant Job\n  1376→    participant TargetURL\n  1377→\n  1378→    DomainEvent->>Listener: Event fired (e.g., OrderCreated)\n  1379→    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active\n  1380→    loop For each matching subscription\n  1381→        Listener->>Queue: Dispatch DeliverWebhook job\n  1382→    end\n  1383→    Queue->>Job: Process job\n  1384→    Job->>Job: Serialize event payload as JSON\n  1385→    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)\n  1386→    Job->>TargetURL: HTTP POST with headers and payload\n  1387→    TargetURL-->>Job: Response\n  1388→    Job->>Job: Record result in webhook_deliveries\n  1389→```\n  1390→\n  1391→**Required HTTP headers on delivery:**\n  1392→\n  1393→| Header | Value |\n  1394→|---|---|\n  1395→| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |\n  1396→| `X-Platform-Event` | Event type string (e.g., `order.created`) |\n  1397→| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |\n  1398→| `X-Platform-Timestamp` | Unix timestamp of the event |\n  1399→| `Content-Type` | `application/json` |\n  1400→\n  1401→### 13.3 Retry Logic\n  1402→\n  1403→On non-2xx response or connection timeout:\n  1404→\n  1405→| Attempt | Backoff Delay |\n  1406→|---|---|\n  1407→| 1 (initial) | Immediate |\n  1408→| 2 | 1 minute |\n  1409→| 3 | 5 minutes |\n  1410→| 4 | 30 minutes |\n  1411→| 5 | 2 hours |\n  1412→| 6 (final) | 12 hours |\n  1413→\n  1414→After 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).\n  1415→\n  1416→Use Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.\n  1417→\n  1418→### 13.4 Circuit Breaker\n  1419→\n  1420→- Track consecutive failures per subscription (across different events).\n  1421→- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.\n  1422→- Log a warning and (future) send an admin notification.\n  1423→- Manual re-enable required by the merchant through the admin UI.\n  1424→- On each successful delivery, reset the consecutive failure counter to 0.\n  1425→\n  1426→**Service:** `App\\Services\\WebhookService`\n  1427→\n  1428→---\n  1429→\n  1430→## 14. Analytics\n  1431→\n  1432→### 14.1 Event Ingestion\n  1433→\n  1434→**Endpoint:** `POST /api/storefront/v1/analytics/events`\n  1435→\n  1436→**Request structure:**\n  1437→\n  1438→| Field | Type | Required | Description |\n  1439→|---|---|---|---|\n  1440→Events are submitted in batches:\n  1441→\n  1442→```json\n  1443→{\n  1444→  \"events\": [\n  1445→    {\n  1446→      \"type\": \"page_view\",\n  1447→      \"session_id\": \"...\",\n  1448→      \"client_event_id\": \"...\",\n  1449→      \"occurred_at\": \"2025-01-15T10:30:00Z\",\n  1450→      \"properties\": { ... }\n  1451→    }\n  1452→  ]\n  1453→}\n  1454→```\n  1455→\n  1456→**Event fields:**\n  1457→\n  1458→| Field | Type | Required | Description |\n  1459→|---|---|---|---|\n  1460→| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |\n  1461→| `session_id` | string | Yes | Client session identifier |\n  1462→| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |\n  1463→| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |\n  1464→| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |\n  1465→\n  1466→**Deduplication:** Events are deduplicated by `client_event_id` within the store scope. If a `client_event_id` has already been recorded for the current store (enforced by the unique index on `store_id, client_event_id`), the duplicate event is silently dropped.\n  1467→\n  1468→**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.\n  1469→\n  1470→### 14.2 Aggregation\n  1471→\n  1472→**Job:** `App\\Jobs\\AggregateAnalytics`\n  1473→\n  1474→- Schedule: daily at 01:00 UTC\n  1475→- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.\n  1476→\n  1477→**Aggregated metrics:**\n  1478→\n  1479→| Metric | Calculation |\n  1480→|---|---|\n  1481→| `orders_count` | Count of `checkout_completed` events for the day |\n  1482→| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |\n  1483→| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |\n  1484→| `visits_count` | Count of distinct `session_id` values with `page_view` events |\n  1485→| `add_to_cart_count` | Count of `add_to_cart` events |\n  1486→| `checkout_started_count` | Count of `checkout_started` events |\n  1487→\n  1488→---\n  1489→\n  1490→## 15. Media Processing\n  1491→\n  1492→### 15.1 Upload Flow\n  1493→\n  1494→```mermaid\n  1495→flowchart TD\n  1496→    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]\n  1497→    B --> C[Create product_media record with status = processing]\n  1498→    C --> D[Dispatch ProcessMediaUpload job to queue]\n  1499→    D --> E{Processing succeeds?}\n  1500→    E -->|Yes| F[Store resized versions, set status = ready]\n  1501→    E -->|No| G[Set status = failed, log error, keep original]\n  1502→```\n  1503→\n  1504→### 15.2 Processing\n  1505→\n  1506→**Job:** `App\\Jobs\\ProcessMediaUpload`\n  1507→\n  1508→**Resize targets:**\n  1509→\n  1510→| Key | Max Dimensions | Use Case |\n  1511→|---|---|---|\n  1512→| `thumbnail` | 150x150 px | Admin grid, small previews |\n  1513→| `small` | 300x300 px | Product cards, listings |\n  1514→| `medium` | 600x600 px | Product detail page |\n  1515→| `large` | 1200x1200 px | Full-size product image, zoom |\n  1516→\n  1517→**Processing rules:**\n  1518→- Maintain aspect ratio (contain within target dimensions, no cropping).\n  1519→- Generate WebP version alongside the original format if the server supports it.\n  1520→- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`\n  1521→- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.\n  1522→- On failure: set `status = 'failed'` and log the error. Do not delete the original file.\n  1523→- Maximum 3 retry attempts before permanent failure.\n  1524→\n  1525→### 15.3 Cleanup\n  1526→\n  1527→When a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).\n  1528→\n  1529→---\n  1530→\n  1531→## 16. Search (SQLite FTS5)\n  1532→\n  1533→### 16.1 Index Setup\n  1534→\n  1535→Create an FTS5 virtual table named `products_fts` with these columns:\n  1536→\n  1537→| Column | Indexed | Description |\n  1538→|---|---|---|\n  1539→| `store_id` | No (UNINDEXED) | Used for filtering, not searching |\n  1540→| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |\n  1541→| `title` | Yes | Product title, fully searchable |\n  1542→| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |\n  1543→| `vendor` | Yes | Vendor name |\n  1544→| `product_type` | Yes | Product type |\n  1545→| `tags` | Yes | Space-separated tag values |\n  1546→\n  1547→### 16.2 Index Synchronization\n  1548→\n  1549→**Observer:** `App\\Observers\\ProductObserver`\n  1550→\n  1551→| Model Event | FTS Action |\n  1552→|---|---|\n  1553→| `created` | Insert row into products_fts |\n  1554→| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |\n  1555→| `deleted` | Delete row from products_fts |\n  1556→\n  1557→**Data preparation before insert:**\n  1558→- Strip HTML tags from `description_html` to get plain text.\n  1559→- Convert JSON tags array to space-separated string.\n  1560→- Use empty string for null vendor and product_type fields.\n  1561→\n  1562→### 16.3 Search Query\n  1563→\n  1564→**Service:** `App\\Services\\SearchService`\n  1565→\n  1566→**Search algorithm:**\n  1567→\n  1568→```\n  1569→PROCEDURE search(store, queryString, limit = 20):\n  1570→    1. Sanitize queryString: escape FTS5 special characters\n  1571→    2. Append '*' to last token for prefix matching (autocomplete)\n  1572→       Example: \"running sh\" becomes \"running sh*\"\n  1573→    3. Execute FTS5 MATCH query:\n  1574→       - Filter by store_id\n  1575→       - Order by FTS5 relevance rank\n  1576→       - Limit results\n  1577→    4. Load matching Product models\n  1578→    5. Exclude products with status != 'active' or published_at IS NULL\n  1579→    6. RETURN ordered collection\n  1580→```\n  1581→\n  1582→### 16.4 Search Logging\n  1583→\n  1584→Each search query is logged to `search_queries` with: store_id, query text, results_count, created_at. This data feeds into analytics and can inform autocomplete suggestions.\n  1585→\n  1586→---\n  1587→\n  1588→## 17. Event and Listener Registry\n  1589→\n  1590→Summary of all domain events and their listeners/side effects:\n  1591→\n  1592→| Event | Listeners / Side Effects |\n  1593→|---|---|\n  1594→| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |\n  1595→| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |\n  1596→| `CheckoutAddressed` | Recalculate pricing |\n  1597→| `CheckoutShippingSelected` | Recalculate pricing |\n  1598→| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |\n  1599→| `CheckoutExpired` | Release reserved inventory |\n  1600→| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |\n  1601→| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |\n  1602→| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |\n  1603→| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |\n  1604→| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |\n  1605→| `FulfillmentCreated` | Update order fulfillment status |\n  1606→| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |\n  1607→| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |\n  1608→| `OrderCancelled` | Release reserved inventory, send cancellation email |\n  1609→\n  1610→All events should be placed in `App\\Events`. All listeners in `App\\Listeners`.\n  1611→\n  1612→---\n  1613→\n  1614→## 18. Scheduled Jobs Registry\n  1615→\n  1616→| Job Class | Schedule | Description |\n  1617→|---|---|---|\n  1618→| `App\\Jobs\\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |\n  1619→| `App\\Jobs\\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |\n  1620→| `App\\Jobs\\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |\n  1621→| `App\\Jobs\\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |\n  1622→\n  1623→**Queued jobs (dispatched on demand, not scheduled):**\n  1624→\n  1625→| Job Class | Description |\n  1626→|---|---|\n  1627→| `App\\Jobs\\ProcessMediaUpload` | Resize uploaded images to standard sizes |\n  1628→| `App\\Jobs\\DeliverWebhook` | POST webhook payload to subscriber endpoint |\n  1629→\n  1630→Schedule registration belongs in `routes/console.php`.\n  1631→\n  1632→---\n  1633→\n  1634→## 19. Service Class Registry\n  1635→\n  1636→| Service | Namespace | Responsibility |\n  1637→|---|---|---|\n  1638→| ProductService | `App\\Services` | Product CRUD, status transitions |\n  1639→| VariantMatrixService | `App\\Services` | Option/variant matrix management |\n  1640→| InventoryService | `App\\Services` | Reserve, release, commit, restock operations |\n  1641→| CartService | `App\\Services` | Cart CRUD, line management, merge on login |\n  1642→| PricingEngine | `App\\Services` | Deterministic total calculation pipeline |\n  1643→| CheckoutService | `App\\Services` | Checkout state machine transitions |\n  1644→| DiscountService | `App\\Services` | Discount validation and application |\n  1645→| TaxCalculator | `App\\Services` | Tax calculation orchestration (delegates to providers) |\n  1646→| ManualTaxProvider | `App\\Services\\Tax` | Manual tax rate calculation |\n  1647→| StripeTaxProvider | `App\\Services\\Tax` | Stripe Tax API integration (stub) |\n  1648→| ShippingCalculator | `App\\Services` | Zone matching, rate calculation |\n  1649→| PaymentService | `App\\Services` | Payment processing orchestration |\n  1650→| MockPaymentProvider | `App\\Services\\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |\n  1651→| RefundService | `App\\Services` | Refund processing and inventory restock |\n  1652→| FulfillmentService | `App\\Services` | Fulfillment creation and status management |\n  1653→| CustomerService | `App\\Services` | Customer registration, account management |\n  1654→| WebhookService | `App\\Services` | Webhook delivery, circuit breaker |\n  1655→| SearchService | `App\\Services` | FTS5 search queries |\n  1656→| HandleGenerator | `App\\Support` | Unique handle/slug generation |\n  1657→\n  1658→---\n  1659→\n  1660→## 20. Contracts (Interfaces)\n  1661→\n  1662→| Interface | Namespace | Implementing Classes |\n  1663→|---|---|---|\n  1664→| TaxProvider | `App\\Contracts` | ManualTaxProvider, StripeTaxProvider |\n  1665→| PaymentProvider | `App\\Contracts` | MockPaymentProvider |\n  1666→\n  1667→---\n  1668→\n  1669→## 21. Enum Registry\n  1670→\n  1671→All enums live in `App\\Enums` and are string-backed.\n  1672→\n  1673→| Enum | Values (TitleCase keys, lowercase string values) |\n  1674→|---|---|\n  1675→| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |\n  1676→| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |\n  1677→| VariantStatus | Active (`active`), Archived (`archived`) |\n  1678→| DiscountType | Code (`code`), Automatic (`automatic`) |\n  1679→| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |\n  1680→| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |\n  1681→| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentSelected (`payment_selected`), Completed (`completed`), Expired (`expired`) |\n  1682→| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |\n  1683→| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |\n  1684→| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |\n  1685→| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |\n  1686→| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |\n  1687→| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |\n  1688→| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |\n  1689→| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |\n  1690→| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |\n  1691→\n  1692→---\n  1693→\n  1694→## 22. Value Object Registry\n  1695→\n  1696→All value objects live in `App\\ValueObjects`.\n  1697→\n  1698→| Value Object | Fields | Purpose |\n  1699→|---|---|---|\n  1700→| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |\n  1701→| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |\n  1702→| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |\n  1703→| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |\n  1704→| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |\n  1705→| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |\n  1706→| WebhookResult | success (bool), message (string) | Webhook processing result |\n  1707→| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |\n  1708→| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |\n  1709→| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |\n  1710→"}]},"uuid":"6b26ac9c-e4b7-4ec0-8335-1017cfa5fe42","timestamp":"2026-02-13T15:23:51.347Z","sourceToolAssistantUUID":"eff265da-84ba-4ffb-820f-1184927f7071"}
{"parentUuid":"6b26ac9c-e4b7-4ec0-8335-1017cfa5fe42","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_01LUsWcK3ZoS3W28gUhh5B2W","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018JZPWQ2uR4BPHEyngoHuWe","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6316,"cache_read_input_tokens":16158,"cache_creation":{"ephemeral_5m_input_tokens":6316,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JHKiAbTJ7trpMRug9i","type":"assistant","uuid":"f68488d3-b16c-483f-a00f-1b00f02b4a54","timestamp":"2026-02-13T15:23:51.565Z"}
{"parentUuid":"f68488d3-b16c-483f-a00f-1b00f02b4a54","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"File content (32506 tokens) exceeds maximum allowed tokens (25000). Please use offset and limit parameters to read specific portions of the file, or use the GrepTool to search for specific content.","is_error":true,"tool_use_id":"toolu_018JZPWQ2uR4BPHEyngoHuWe"}]},"uuid":"b008cf5e-1587-47d8-8862-52229cc6b55b","timestamp":"2026-02-13T15:23:51.886Z","toolUseResult":"Error: File content (32506 tokens) exceeds maximum allowed tokens (25000). Please use offset and limit parameters to read specific portions of the file, or use the GrepTool to search for specific content.","sourceToolAssistantUUID":"f68488d3-b16c-483f-a00f-1b00f02b4a54"}
{"parentUuid":"b008cf5e-1587-47d8-8862-52229cc6b55b","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_01PwCwQBuhnrPkhKZoTJKFnf","type":"message","role":"assistant","content":[{"type":"text","text":"Let me read the implementation roadmap in portions and examine the existing codebase patterns."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":52408,"cache_read_input_tokens":22474,"cache_creation":{"ephemeral_5m_input_tokens":52408,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JHexo9uSdo5pHdv7CN","type":"assistant","uuid":"41e31cee-36f2-4248-aec7-fd54e29bea0f","timestamp":"2026-02-13T15:23:55.178Z"}
{"parentUuid":"41e31cee-36f2-4248-aec7-fd54e29bea0f","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_01PwCwQBuhnrPkhKZoTJKFnf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ETxHFuQqj4UmzEnedaaN6T","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/specs/09-IMPLEMENTATION-ROADMAP.md","limit":200,"offset":1}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":52408,"cache_read_input_tokens":22474,"cache_creation":{"ephemeral_5m_input_tokens":52408,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JHexo9uSdo5pHdv7CN","type":"assistant","uuid":"242636a9-b2a5-45a8-a903-5fa853291706","timestamp":"2026-02-13T15:23:56.738Z"}
{"parentUuid":"242636a9-b2a5-45a8-a903-5fa853291706","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01ETxHFuQqj4UmzEnedaaN6T","toolUseID":"toolu_01ETxHFuQqj4UmzEnedaaN6T","timestamp":"2026-02-13T15:23:56.747Z","uuid":"c4de2c60-313e-4847-a5dd-fbcedb9cc92c"}
{"parentUuid":"242636a9-b2a5-45a8-a903-5fa853291706","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01ETxHFuQqj4UmzEnedaaN6T","type":"tool_result","content":"     1→# 09 - Implementation Roadmap and Pest Test Specification\n     2→\n     3→> E-commerce platform - Step-by-step build order and exhaustive test plan for Laravel 12 + Livewire v4 + Flux UI Free + SQLite + Tailwind CSS v4\n     4→\n     5→---\n     6→\n     7→## 1. Technology Decisions (Final)\n     8→\n     9→These decisions adapt the original PDF spec for a self-contained, benchmarkable Laravel application. All decisions are final and must not be revisited.\n    10→\n    11→| Concern | PDF Default | Our Decision | Rationale |\n    12→|---------|-------------|--------------|-----------|\n    13→| Database | PostgreSQL | SQLite | Self-contained, no external DB, zero config |\n    14→| Cache | Redis | File (Array for tests) | No Redis dependency |\n    15→| Session | Redis | File | No Redis dependency |\n    16→| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |\n    17→| Search | Elasticsearch + Scout | SQLite FTS5 | Self-contained, no external service |\n    18→| Storage | S3 | Local filesystem (storage/app/public) | No AWS dependency |\n    19→| Admin UI | React/Vue SPA | Livewire v4 + Flux UI Free | Server-rendered, matches project stack |\n    20→| Storefront | Blade SSR + Vite | Blade + Livewire + Tailwind v4 | Server-rendered, matches project stack |\n    21→| Auth (admin) | Sanctum SPA | Laravel session auth | Standard Livewire auth pattern |\n    22→| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |\n    23→| Auth (customer) | Separate guard | Custom `customer` guard | Storefront customer auth |\n    24→| OAuth (apps) | Passport | Deferred/Stubbed | Simplifies initial build, can add later |\n    25→| Payments | Stripe + PayPal | Mock PSP (in-process) | No external API calls. MockPaymentProvider handles credit card, PayPal, bank transfer with magic card numbers for testing. |\n    26→| Email | SMTP/SES | Log driver (dev) / SMTP | Log for benchmark, SMTP configurable |\n    27→| Monitoring | OpenTelemetry | Laravel Log (structured JSON) | Simplified for benchmark |\n    28→| CI/CD | GitHub Actions | Not implemented | Out of scope for one-shot benchmark |\n    29→| Docker | Multi-stage | Not implemented | App served by Laravel Herd locally |\n    30→\n    31→---\n    32→\n    33→## 2. Implementation Phases (Build Order)\n    34→\n    35→Each phase builds on the previous. A coding agent should execute these in strict sequential order. Tests are written alongside each phase (see Section 3 for the full test specification).\n    36→\n    37→### Phase Dependency Graph\n    38→\n    39→```mermaid\n    40→flowchart TD\n    41→    P1[Phase 1: Foundation]\n    42→    P2[Phase 2: Catalog]\n    43→    P3[Phase 3: Themes and Storefront Layout]\n    44→    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]\n    45→    P5[Phase 5: Payments, Orders, Fulfillment]\n    46→    P6[Phase 6: Customer Accounts]\n    47→    P7[Phase 7: Admin Panel]\n    48→    P8[Phase 8: Search]\n    49→    P9[Phase 9: Analytics]\n    50→    P10[Phase 10: Apps and Webhooks]\n    51→    P11[Phase 11: Polish]\n    52→    P12[Phase 12: Full Test Suite]\n    53→\n    54→    P1 --> P2\n    55→    P1 --> P3\n    56→    P2 --> P4\n    57→    P3 --> P4\n    58→    P4 --> P5\n    59→    P5 --> P6\n    60→    P5 --> P7\n    61→    P2 --> P8\n    62→    P5 --> P9\n    63→    P5 --> P10\n    64→    P6 --> P11\n    65→    P7 --> P11\n    66→    P8 --> P11\n    67→    P9 --> P11\n    68→    P10 --> P11\n    69→    P11 --> P12\n    70→```\n    71→\n    72→---\n    73→\n    74→### Phase 1: Foundation (Migrations, Models, Middleware, Auth)\n    75→\n    76→**Priority:** CRITICAL - everything depends on this.\n    77→\n    78→#### Step 1.1: Environment and Config\n    79→\n    80→| Config File | Setting | Value | Purpose |\n    81→|-------------|---------|-------|---------|\n    82→| `.env` | `DB_CONNECTION` | `sqlite` | Use SQLite as database |\n    83→| `.env` | `DB_DATABASE` | `database/database.sqlite` | SQLite database file path |\n    84→| `.env` | `CACHE_STORE` | `file` | File-based caching |\n    85→| `.env` | `SESSION_DRIVER` | `file` | File-based sessions |\n    86→| `.env` | `QUEUE_CONNECTION` | `sync` | Synchronous queue for dev |\n    87→| `.env` | `MAIL_MAILER` | `log` | Log-based mail for dev |\n    88→| `.env` | `FILESYSTEM_DISK` | `local` | Local filesystem storage |\n    89→| `config/database.php` | SQLite connection | WAL mode, foreign keys enabled, busy_timeout=5000, synchronous=normal | Optimized SQLite configuration |\n    90→| `config/session.php` | `driver` | `file` | Session driver |\n    91→| `config/session.php` | `lifetime` | `120` | Session lifetime in minutes |\n    92→| `config/cache.php` | `default` | `file` | Cache driver |\n    93→| `config/queue.php` | `default` | `sync` | Queue driver |\n    94→| `config/auth.php` | Guards | Add `customer` guard with `customers` provider | Customer storefront authentication |\n    95→| `config/auth.php` | Providers | Add `customers` provider pointing to Customer model | Customer user provider |\n    96→| `config/auth.php` | Passwords | Add `customers` password broker | Customer password reset |\n    97→| `config/filesystems.php` | `public` disk | Ensure configured for local storage | Media file storage |\n    98→| `config/logging.php` | Channels | Add structured JSON channel | Structured logging |\n    99→\n   100→#### Step 1.2: Core Migrations (Batch 1-2)\n   101→\n   102→Create in this dependency order:\n   103→\n   104→1. `create_organizations_table` - no dependencies\n   105→2. `create_stores_table` - FK: `organization_id`\n   106→3. `create_store_domains_table` - FK: `store_id`\n   107→4. Modify default `users` migration - add `status`, `last_login_at`, `two_factor_*` columns; rename `password` to `password_hash` internally\n   108→5. `create_store_users_table` - composite PK: `store_id` + `user_id`, with `role` column\n   109→6. `create_store_settings_table` - PK: `store_id`\n   110→\n   111→All monetary amounts stored as INTEGER in minor units (cents). All enums stored as TEXT with CHECK constraints. All timestamps as TEXT (ISO-8601) via Laravel.\n   112→\n   113→#### Step 1.3: Core Models\n   114→\n   115→**Files to create:**\n   116→\n   117→| Type | Name | Purpose |\n   118→|------|------|---------|\n   119→| Model (with migration, factory, seeder) | `Organization` | Top-level tenant entity |\n   120→| Model (with migration, factory, seeder) | `Store` | Individual store within an organization |\n   121→| Model (with migration, factory, seeder) | `StoreDomain` | Domain mapping for a store |\n   122→| Model (pivot) | `StoreUser` | Pivot model with `role` attribute, custom Pivot class |\n   123→| Model (with migration, factory, seeder) | `StoreSettings` | Per-store configuration |\n   124→\n   125→**Relationship definitions:**\n   126→\n   127→| Model | Key Relationships |\n   128→|-------|-------------------|\n   129→| `Organization` | `hasMany(Store::class)` |\n   130→| `Store` | `belongsTo(Organization::class)`, `hasMany(StoreDomain::class)`, `belongsToMany(User::class)` through `store_users`, `hasOne(StoreSettings::class)` |\n   131→| `StoreDomain` | `belongsTo(Store::class)` |\n   132→| `User` | `belongsToMany(Store::class)` through `store_users` |\n   133→| `StoreUser` | Pivot model with `role` attribute, custom `Pivot` class |\n   134→| `StoreSettings` | `belongsTo(Store::class)` |\n   135→\n   136→Each model must have:\n   137→- Explicit return type declarations on all methods\n   138→- Proper `$fillable` or `$guarded` arrays\n   139→- `casts()` method for JSON/enum columns\n   140→- Factory with meaningful default values\n   141→- Seeder that creates sample data\n   142→\n   143→#### Step 1.4: Enums\n   144→\n   145→**Files to create in `app/Enums/`:**\n   146→\n   147→| Enum | Values |\n   148→|------|--------|\n   149→| `StoreStatus` | `Active`, `Suspended` |\n   150→| `StoreUserRole` | `Owner`, `Admin`, `Staff`, `Support` |\n   151→| `StoreDomainType` | `Storefront`, `Admin`, `Api` |\n   152→\n   153→#### Step 1.5: Tenant Resolution Middleware\n   154→\n   155→**File to create:** `App\\Http\\Middleware\\ResolveStore`\n   156→\n   157→Requirements:\n   158→1. Storefront routes: extract hostname from the request, look up `store_domains`, cache hostname-to-store_id for 5 minutes, bind `Store` as singleton via container instance `current_store`\n   159→2. Admin routes: read `current_store_id` from session, verify user has `store_users` record\n   160→3. Return 404 if hostname not found\n   161→4. Return 503 for suspended stores on storefront routes\n   162→\n   163→**Registration in `bootstrap/app.php`:**\n   164→- Register middleware group `storefront` containing `ResolveStore`\n   165→- Register middleware group `admin` containing `ResolveStore`\n   166→\n   167→#### Step 1.6: BelongsToStore Trait and Global Scope\n   168→\n   169→**Files to create:**\n   170→\n   171→| Type | Name | Purpose |\n   172→|------|------|---------|\n   173→| Trait | `App\\Models\\Concerns\\BelongsToStore` | Applies StoreScope and auto-sets store_id on creating event |\n   174→| Scope | `App\\Models\\Scopes\\StoreScope` | Applies `where('store_id', app('current_store')->id)` to all queries |\n   175→\n   176→Applied on: products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.\n   177→\n   178→#### Step 1.7: Authentication\n   179→\n   180→**Admin auth requirements:**\n   181→\n   182→| Component | Description |\n   183→|-----------|-------------|\n   184→| Livewire component `Admin\\Auth\\Login` | Admin login form |\n   185→| Livewire component `Admin\\Auth\\Logout` | Admin logout action |\n   186→| Auth mechanism | Standard Laravel session auth via `Auth::guard('web')->attempt()` |\n   187→| Rate limiting | 5 attempts per minute per IP via `RateLimiter::for('login')` |\n   188→| Password reset | Standard Laravel flow with `Password::broker('users')` |\n   189→\n   190→**Customer auth requirements:**\n   191→\n   192→| Component | Description |\n   193→|-----------|-------------|\n   194→| Custom guard | `customer` guard with `customers` provider |\n   195→| Custom user provider | `CustomerUserProvider` that injects `store_id` into queries |\n   196→| Livewire component `Storefront\\Account\\Auth\\Login` | Customer login form |\n   197→| Livewire component `Storefront\\Account\\Auth\\Register` | Customer registration form |\n   198→| Uniqueness | Customer email unique per store, not globally |\n   199→| Rate limiting | 5 attempts per minute per IP |\n   200→"}]},"uuid":"80682427-7d2a-4715-84ac-f3b03d3ea9ca","timestamp":"2026-02-13T15:23:56.745Z","sourceToolAssistantUUID":"242636a9-b2a5-45a8-a903-5fa853291706"}
{"parentUuid":"80682427-7d2a-4715-84ac-f3b03d3ea9ca","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_01PwCwQBuhnrPkhKZoTJKFnf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Ejr6xGZSZqMs7diru471Dy","name":"Glob","input":{"pattern":"database/migrations/*.php","path":"/Users/fabianwesner/Herd/shop"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":52408,"cache_read_input_tokens":22474,"cache_creation":{"ephemeral_5m_input_tokens":52408,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JHexo9uSdo5pHdv7CN","type":"assistant","uuid":"1664654a-076f-41c0-acea-da2822da638c","timestamp":"2026-02-13T15:23:57.354Z"}
{"parentUuid":"1664654a-076f-41c0-acea-da2822da638c","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Glob","command":"callback"},"parentToolUseID":"toolu_01Ejr6xGZSZqMs7diru471Dy","toolUseID":"toolu_01Ejr6xGZSZqMs7diru471Dy","timestamp":"2026-02-13T15:23:57.886Z","uuid":"4b97fd3c-5305-41f0-bc89-20bda157d08a"}
{"parentUuid":"1664654a-076f-41c0-acea-da2822da638c","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Ejr6xGZSZqMs7diru471Dy","type":"tool_result","content":"/Users/fabianwesner/Herd/shop/database/migrations/0001_01_01_000001_create_cache_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/0001_01_01_000002_create_jobs_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/0001_01_01_000000_create_users_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000010_create_organizations_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000020_create_stores_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000030_create_store_domains_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000040_add_status_and_last_login_to_users_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000050_create_store_users_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000060_create_store_settings_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000100_create_products_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000110_create_product_options_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000120_create_product_option_values_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000130_create_product_variants_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000140_create_variant_option_values_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000150_create_inventory_items_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000160_create_collections_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000170_create_collection_products_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000180_create_product_media_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000200_create_themes_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000210_create_theme_files_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000220_create_theme_settings_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000230_create_pages_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000240_create_navigation_menus_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000250_create_navigation_items_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000300_create_carts_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000310_create_cart_lines_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000320_create_shipping_zones_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000330_create_shipping_rates_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000340_create_tax_settings_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000350_create_discounts_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000360_create_checkouts_table.php\n/Users/fabianwesner/Herd/shop/database/migrations/2025_01_01_000290_create_customers_table.php"}]},"uuid":"a9319812-8338-491c-a727-025df9d2d625","timestamp":"2026-02-13T15:23:57.885Z","sourceToolAssistantUUID":"1664654a-076f-41c0-acea-da2822da638c"}
{"parentUuid":"a9319812-8338-491c-a727-025df9d2d625","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_01PwCwQBuhnrPkhKZoTJKFnf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WScZuhMRrRow7pckPGDC8a","name":"Glob","input":{"pattern":"app/Models/*.php","path":"/Users/fabianwesner/Herd/shop"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":52408,"cache_read_input_tokens":22474,"cache_creation":{"ephemeral_5m_input_tokens":52408,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JHexo9uSdo5pHdv7CN","type":"assistant","uuid":"a1d0081e-4fac-4164-9565-b605b958e777","timestamp":"2026-02-13T15:23:57.978Z"}
{"parentUuid":"a1d0081e-4fac-4164-9565-b605b958e777","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Glob","command":"callback"},"parentToolUseID":"toolu_01WScZuhMRrRow7pckPGDC8a","toolUseID":"toolu_01WScZuhMRrRow7pckPGDC8a","timestamp":"2026-02-13T15:23:58.284Z","uuid":"8669bb2d-d3a9-40e8-9ae1-c41a043ac131"}
{"parentUuid":"a1d0081e-4fac-4164-9565-b605b958e777","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WScZuhMRrRow7pckPGDC8a","type":"tool_result","content":"/Users/fabianwesner/Herd/shop/app/Models/Organization.php\n/Users/fabianwesner/Herd/shop/app/Models/StoreDomain.php\n/Users/fabianwesner/Herd/shop/app/Models/StoreSettings.php\n/Users/fabianwesner/Herd/shop/app/Models/Customer.php\n/Users/fabianwesner/Herd/shop/app/Models/StoreUser.php\n/Users/fabianwesner/Herd/shop/app/Models/Store.php\n/Users/fabianwesner/Herd/shop/app/Models/User.php\n/Users/fabianwesner/Herd/shop/app/Models/Product.php\n/Users/fabianwesner/Herd/shop/app/Models/ProductOption.php\n/Users/fabianwesner/Herd/shop/app/Models/ProductOptionValue.php\n/Users/fabianwesner/Herd/shop/app/Models/ProductVariant.php\n/Users/fabianwesner/Herd/shop/app/Models/InventoryItem.php\n/Users/fabianwesner/Herd/shop/app/Models/Collection.php\n/Users/fabianwesner/Herd/shop/app/Models/ProductMedia.php\n/Users/fabianwesner/Herd/shop/app/Models/Theme.php\n/Users/fabianwesner/Herd/shop/app/Models/ThemeFile.php\n/Users/fabianwesner/Herd/shop/app/Models/ThemeSettings.php\n/Users/fabianwesner/Herd/shop/app/Models/Page.php\n/Users/fabianwesner/Herd/shop/app/Models/NavigationMenu.php\n/Users/fabianwesner/Herd/shop/app/Models/NavigationItem.php\n/Users/fabianwesner/Herd/shop/app/Models/Cart.php\n/Users/fabianwesner/Herd/shop/app/Models/CartLine.php\n/Users/fabianwesner/Herd/shop/app/Models/ShippingZone.php\n/Users/fabianwesner/Herd/shop/app/Models/ShippingRate.php\n/Users/fabianwesner/Herd/shop/app/Models/TaxSettings.php\n/Users/fabianwesner/Herd/shop/app/Models/Discount.php\n/Users/fabianwesner/Herd/shop/app/Models/Checkout.php"}]},"uuid":"a4f2ba02-6c71-4ad2-8137-d645fc279c5e","timestamp":"2026-02-13T15:23:58.283Z","sourceToolAssistantUUID":"a1d0081e-4fac-4164-9565-b605b958e777"}
{"parentUuid":"a4f2ba02-6c71-4ad2-8137-d645fc279c5e","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_01PwCwQBuhnrPkhKZoTJKFnf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WU2AQg13mgXbaeuGzSMWfT","name":"Glob","input":{"pattern":"app/Enums/*.php","path":"/Users/fabianwesner/Herd/shop"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":52408,"cache_read_input_tokens":22474,"cache_creation":{"ephemeral_5m_input_tokens":52408,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JHexo9uSdo5pHdv7CN","type":"assistant","uuid":"c8fbd623-0e81-4442-aef8-4a0f91c597be","timestamp":"2026-02-13T15:23:58.505Z"}
{"parentUuid":"c8fbd623-0e81-4442-aef8-4a0f91c597be","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Glob","command":"callback"},"parentToolUseID":"toolu_01WU2AQg13mgXbaeuGzSMWfT","toolUseID":"toolu_01WU2AQg13mgXbaeuGzSMWfT","timestamp":"2026-02-13T15:23:58.824Z","uuid":"114cc3e3-bd19-4688-af1c-b894ba5ed643"}
{"parentUuid":"c8fbd623-0e81-4442-aef8-4a0f91c597be","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WU2AQg13mgXbaeuGzSMWfT","type":"tool_result","content":"/Users/fabianwesner/Herd/shop/app/Enums/StoreStatus.php\n/Users/fabianwesner/Herd/shop/app/Enums/StoreUserRole.php\n/Users/fabianwesner/Herd/shop/app/Enums/StoreDomainType.php\n/Users/fabianwesner/Herd/shop/app/Enums/ProductStatus.php\n/Users/fabianwesner/Herd/shop/app/Enums/VariantStatus.php\n/Users/fabianwesner/Herd/shop/app/Enums/CollectionStatus.php\n/Users/fabianwesner/Herd/shop/app/Enums/MediaType.php\n/Users/fabianwesner/Herd/shop/app/Enums/MediaStatus.php\n/Users/fabianwesner/Herd/shop/app/Enums/InventoryPolicy.php\n/Users/fabianwesner/Herd/shop/app/Enums/CollectionType.php\n/Users/fabianwesner/Herd/shop/app/Enums/ThemeStatus.php\n/Users/fabianwesner/Herd/shop/app/Enums/PageStatus.php\n/Users/fabianwesner/Herd/shop/app/Enums/NavigationItemType.php\n/Users/fabianwesner/Herd/shop/app/Enums/CartStatus.php\n/Users/fabianwesner/Herd/shop/app/Enums/CheckoutStatus.php\n/Users/fabianwesner/Herd/shop/app/Enums/DiscountType.php\n/Users/fabianwesner/Herd/shop/app/Enums/DiscountValueType.php\n/Users/fabianwesner/Herd/shop/app/Enums/DiscountStatus.php\n/Users/fabianwesner/Herd/shop/app/Enums/ShippingRateType.php\n/Users/fabianwesner/Herd/shop/app/Enums/TaxMode.php"}]},"uuid":"62e41aea-a090-4eb1-8e1e-a8b77f1f9c2d","timestamp":"2026-02-13T15:23:58.823Z","sourceToolAssistantUUID":"c8fbd623-0e81-4442-aef8-4a0f91c597be"}
{"parentUuid":"62e41aea-a090-4eb1-8e1e-a8b77f1f9c2d","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_01PwCwQBuhnrPkhKZoTJKFnf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Mtk4rsLBLHcnfbTRvoYUpv","name":"Glob","input":{"pattern":"app/Services/**/*.php","path":"/Users/fabianwesner/Herd/shop"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":52408,"cache_read_input_tokens":22474,"cache_creation":{"ephemeral_5m_input_tokens":52408,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JHexo9uSdo5pHdv7CN","type":"assistant","uuid":"a8c1597e-2069-4dc4-ac26-a5db5022c50d","timestamp":"2026-02-13T15:23:58.989Z"}
{"parentUuid":"a8c1597e-2069-4dc4-ac26-a5db5022c50d","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Glob","command":"callback"},"parentToolUseID":"toolu_01Mtk4rsLBLHcnfbTRvoYUpv","toolUseID":"toolu_01Mtk4rsLBLHcnfbTRvoYUpv","timestamp":"2026-02-13T15:23:59.297Z","uuid":"7e195b92-1291-4b97-b43f-3f84035d11a9"}
{"parentUuid":"a8c1597e-2069-4dc4-ac26-a5db5022c50d","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Mtk4rsLBLHcnfbTRvoYUpv","type":"tool_result","content":"/Users/fabianwesner/Herd/shop/app/Services/NavigationService.php\n/Users/fabianwesner/Herd/shop/app/Services/ThemeSettingsService.php\n/Users/fabianwesner/Herd/shop/app/Services/InventoryService.php\n/Users/fabianwesner/Herd/shop/app/Services/ProductService.php\n/Users/fabianwesner/Herd/shop/app/Services/VariantMatrixService.php\n/Users/fabianwesner/Herd/shop/app/Services/CartService.php\n/Users/fabianwesner/Herd/shop/app/Services/DiscountService.php\n/Users/fabianwesner/Herd/shop/app/Services/ShippingCalculator.php\n/Users/fabianwesner/Herd/shop/app/Services/TaxCalculator.php\n/Users/fabianwesner/Herd/shop/app/Services/CheckoutService.php\n/Users/fabianwesner/Herd/shop/app/Services/PricingEngine.php"}]},"uuid":"e29671d2-92e9-4e61-b155-3a68084daf67","timestamp":"2026-02-13T15:23:59.296Z","sourceToolAssistantUUID":"a8c1597e-2069-4dc4-ac26-a5db5022c50d"}
{"parentUuid":"e29671d2-92e9-4e61-b155-3a68084daf67","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me examine the existing models and services to understand patterns."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"51990e58-6adc-41d1-a292-bf8c1fa5937b","timestamp":"2026-02-13T15:24:01.905Z"}
{"parentUuid":"51990e58-6adc-41d1-a292-bf8c1fa5937b","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019xts3rn7Su7Fe4u83kU9K4","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/app/Models/Checkout.php"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"e9aa7d09-faf6-4e43-8116-5663f4b404eb","timestamp":"2026-02-13T15:24:02.383Z"}
{"parentUuid":"e9aa7d09-faf6-4e43-8116-5663f4b404eb","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_019xts3rn7Su7Fe4u83kU9K4","toolUseID":"toolu_019xts3rn7Su7Fe4u83kU9K4","timestamp":"2026-02-13T15:24:02.403Z","uuid":"75d6d191-c60d-4996-9b24-733e406aa0ae"}
{"parentUuid":"e9aa7d09-faf6-4e43-8116-5663f4b404eb","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019xts3rn7Su7Fe4u83kU9K4","type":"tool_result","content":"     1→<?php\n     2→\n     3→namespace App\\Models;\n     4→\n     5→use App\\Enums\\CheckoutStatus;\n     6→use App\\Models\\Concerns\\BelongsToStore;\n     7→use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\n     8→use Illuminate\\Database\\Eloquent\\Model;\n     9→use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\n    10→\n    11→class Checkout extends Model\n    12→{\n    13→    /** @use HasFactory<\\Database\\Factories\\CheckoutFactory> */\n    14→    use BelongsToStore, HasFactory;\n    15→\n    16→    /**\n    17→     * The attributes that are mass assignable.\n    18→     *\n    19→     * @var list<string>\n    20→     */\n    21→    protected $fillable = [\n    22→        'store_id',\n    23→        'cart_id',\n    24→        'customer_id',\n    25→        'status',\n    26→        'payment_method',\n    27→        'email',\n    28→        'shipping_address_json',\n    29→        'billing_address_json',\n    30→        'shipping_method_id',\n    31→        'discount_code',\n    32→        'tax_provider_snapshot_json',\n    33→        'totals_json',\n    34→        'expires_at',\n    35→    ];\n    36→\n    37→    /**\n    38→     * @return array<string, string>\n    39→     */\n    40→    protected function casts(): array\n    41→    {\n    42→        return [\n    43→            'status' => CheckoutStatus::class,\n    44→            'shipping_address_json' => 'array',\n    45→            'billing_address_json' => 'array',\n    46→            'tax_provider_snapshot_json' => 'array',\n    47→            'totals_json' => 'array',\n    48→            'expires_at' => 'datetime',\n    49→        ];\n    50→    }\n    51→\n    52→    /**\n    53→     * @return BelongsTo<Cart, $this>\n    54→     */\n    55→    public function cart(): BelongsTo\n    56→    {\n    57→        return $this->belongsTo(Cart::class);\n    58→    }\n    59→\n    60→    /**\n    61→     * @return BelongsTo<Customer, $this>\n    62→     */\n    63→    public function customer(): BelongsTo\n    64→    {\n    65→        return $this->belongsTo(Customer::class);\n    66→    }\n    67→}\n    68→"}]},"uuid":"ac41d71a-9bd7-44f0-b272-0cc6ed4a8f9f","timestamp":"2026-02-13T15:24:02.402Z","sourceToolAssistantUUID":"e9aa7d09-faf6-4e43-8116-5663f4b404eb"}
{"parentUuid":"ac41d71a-9bd7-44f0-b272-0cc6ed4a8f9f","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013XQxoK5xrzJdKjCGGWW1Te","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/app/Models/Cart.php"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"9c8ea3c5-c190-4e70-9a2d-7cdc870e4fda","timestamp":"2026-02-13T15:24:02.829Z"}
{"parentUuid":"9c8ea3c5-c190-4e70-9a2d-7cdc870e4fda","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_013XQxoK5xrzJdKjCGGWW1Te","toolUseID":"toolu_013XQxoK5xrzJdKjCGGWW1Te","timestamp":"2026-02-13T15:24:02.836Z","uuid":"7fbbf78f-ddc4-4cfe-aea2-72352aae6ca6"}
{"parentUuid":"9c8ea3c5-c190-4e70-9a2d-7cdc870e4fda","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013XQxoK5xrzJdKjCGGWW1Te","type":"tool_result","content":"     1→<?php\n     2→\n     3→namespace App\\Models;\n     4→\n     5→use App\\Enums\\CartStatus;\n     6→use App\\Models\\Concerns\\BelongsToStore;\n     7→use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\n     8→use Illuminate\\Database\\Eloquent\\Model;\n     9→use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\n    10→use Illuminate\\Database\\Eloquent\\Relations\\HasMany;\n    11→\n    12→class Cart extends Model\n    13→{\n    14→    /** @use HasFactory<\\Database\\Factories\\CartFactory> */\n    15→    use BelongsToStore, HasFactory;\n    16→\n    17→    /**\n    18→     * The attributes that are mass assignable.\n    19→     *\n    20→     * @var list<string>\n    21→     */\n    22→    protected $fillable = [\n    23→        'store_id',\n    24→        'customer_id',\n    25→        'currency',\n    26→        'cart_version',\n    27→        'status',\n    28→    ];\n    29→\n    30→    /**\n    31→     * @return array<string, string>\n    32→     */\n    33→    protected function casts(): array\n    34→    {\n    35→        return [\n    36→            'status' => CartStatus::class,\n    37→            'cart_version' => 'integer',\n    38→        ];\n    39→    }\n    40→\n    41→    /**\n    42→     * @return BelongsTo<Customer, $this>\n    43→     */\n    44→    public function customer(): BelongsTo\n    45→    {\n    46→        return $this->belongsTo(Customer::class);\n    47→    }\n    48→\n    49→    /**\n    50→     * @return HasMany<CartLine, $this>\n    51→     */\n    52→    public function lines(): HasMany\n    53→    {\n    54→        return $this->hasMany(CartLine::class);\n    55→    }\n    56→\n    57→    /**\n    58→     * @return HasMany<Checkout, $this>\n    59→     */\n    60→    public function checkouts(): HasMany\n    61→    {\n    62→        return $this->hasMany(Checkout::class);\n    63→    }\n    64→}\n    65→"}]},"uuid":"6a8d03cf-9a9f-4b09-ae4d-3081ce0a925b","timestamp":"2026-02-13T15:24:02.835Z","sourceToolAssistantUUID":"9c8ea3c5-c190-4e70-9a2d-7cdc870e4fda"}
{"parentUuid":"6a8d03cf-9a9f-4b09-ae4d-3081ce0a925b","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0182nUFcmVxDAxrCCBiFQqBM","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/app/Models/Customer.php"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"9bd8b18e-bc7f-4070-bfab-a72736b86132","timestamp":"2026-02-13T15:24:03.278Z"}
{"parentUuid":"9bd8b18e-bc7f-4070-bfab-a72736b86132","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_0182nUFcmVxDAxrCCBiFQqBM","toolUseID":"toolu_0182nUFcmVxDAxrCCBiFQqBM","timestamp":"2026-02-13T15:24:03.285Z","uuid":"9959f2d3-c967-4543-8857-374fa490c1d8"}
{"parentUuid":"9bd8b18e-bc7f-4070-bfab-a72736b86132","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0182nUFcmVxDAxrCCBiFQqBM","type":"tool_result","content":"     1→<?php\n     2→\n     3→namespace App\\Models;\n     4→\n     5→use App\\Models\\Concerns\\BelongsToStore;\n     6→use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\n     7→use Illuminate\\Foundation\\Auth\\User as Authenticatable;\n     8→use Illuminate\\Notifications\\Notifiable;\n     9→\n    10→class Customer extends Authenticatable\n    11→{\n    12→    /** @use HasFactory<\\Database\\Factories\\CustomerFactory> */\n    13→    use BelongsToStore, HasFactory, Notifiable;\n    14→\n    15→    /**\n    16→     * The attributes that are mass assignable.\n    17→     *\n    18→     * @var list<string>\n    19→     */\n    20→    protected $fillable = [\n    21→        'store_id',\n    22→        'email',\n    23→        'password',\n    24→        'name',\n    25→        'marketing_opt_in',\n    26→    ];\n    27→\n    28→    /**\n    29→     * The attributes that should be hidden for serialization.\n    30→     *\n    31→     * @var list<string>\n    32→     */\n    33→    protected $hidden = [\n    34→        'password',\n    35→        'remember_token',\n    36→    ];\n    37→\n    38→    /**\n    39→     * @return array<string, string>\n    40→     */\n    41→    protected function casts(): array\n    42→    {\n    43→        return [\n    44→            'email_verified_at' => 'datetime',\n    45→            'password' => 'hashed',\n    46→            'marketing_opt_in' => 'boolean',\n    47→        ];\n    48→    }\n    49→}\n    50→"}]},"uuid":"b238cee5-25c9-4341-b250-4e7eb974b3d8","timestamp":"2026-02-13T15:24:03.284Z","sourceToolAssistantUUID":"9bd8b18e-bc7f-4070-bfab-a72736b86132"}
{"parentUuid":"b238cee5-25c9-4341-b250-4e7eb974b3d8","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015kmaSJch8qY5HBnJWJL9Dq","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/app/Models/Product.php"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"3e3cb013-516d-4404-b511-df1099c87b31","timestamp":"2026-02-13T15:24:04.134Z"}
{"parentUuid":"3e3cb013-516d-4404-b511-df1099c87b31","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_015kmaSJch8qY5HBnJWJL9Dq","toolUseID":"toolu_015kmaSJch8qY5HBnJWJL9Dq","timestamp":"2026-02-13T15:24:04.153Z","uuid":"b4b01322-cabe-4299-bc51-23cac268fd59"}
{"parentUuid":"3e3cb013-516d-4404-b511-df1099c87b31","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015kmaSJch8qY5HBnJWJL9Dq","type":"tool_result","content":"     1→<?php\n     2→\n     3→namespace App\\Models;\n     4→\n     5→use App\\Enums\\ProductStatus;\n     6→use App\\Models\\Concerns\\BelongsToStore;\n     7→use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\n     8→use Illuminate\\Database\\Eloquent\\Model;\n     9→use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;\n    10→use Illuminate\\Database\\Eloquent\\Relations\\HasMany;\n    11→\n    12→class Product extends Model\n    13→{\n    14→    /** @use HasFactory<\\Database\\Factories\\ProductFactory> */\n    15→    use BelongsToStore, HasFactory;\n    16→\n    17→    /**\n    18→     * The attributes that are mass assignable.\n    19→     *\n    20→     * @var list<string>\n    21→     */\n    22→    protected $fillable = [\n    23→        'store_id',\n    24→        'title',\n    25→        'handle',\n    26→        'status',\n    27→        'description_html',\n    28→        'vendor',\n    29→        'product_type',\n    30→        'tags',\n    31→        'published_at',\n    32→    ];\n    33→\n    34→    /**\n    35→     * @return array<string, string>\n    36→     */\n    37→    protected function casts(): array\n    38→    {\n    39→        return [\n    40→            'status' => ProductStatus::class,\n    41→            'tags' => 'array',\n    42→            'published_at' => 'datetime',\n    43→        ];\n    44→    }\n    45→\n    46→    /**\n    47→     * @return HasMany<ProductVariant, $this>\n    48→     */\n    49→    public function variants(): HasMany\n    50→    {\n    51→        return $this->hasMany(ProductVariant::class);\n    52→    }\n    53→\n    54→    /**\n    55→     * @return HasMany<ProductOption, $this>\n    56→     */\n    57→    public function options(): HasMany\n    58→    {\n    59→        return $this->hasMany(ProductOption::class);\n    60→    }\n    61→\n    62→    /**\n    63→     * @return HasMany<ProductMedia, $this>\n    64→     */\n    65→    public function media(): HasMany\n    66→    {\n    67→        return $this->hasMany(ProductMedia::class);\n    68→    }\n    69→\n    70→    /**\n    71→     * @return BelongsToMany<Collection, $this>\n    72→     */\n    73→    public function collections(): BelongsToMany\n    74→    {\n    75→        return $this->belongsToMany(Collection::class, 'collection_products')\n    76→            ->withPivot('position');\n    77→    }\n    78→}\n    79→"}]},"uuid":"a509cd61-27ad-433b-a0c9-58d120f01593","timestamp":"2026-02-13T15:24:04.153Z","sourceToolAssistantUUID":"3e3cb013-516d-4404-b511-df1099c87b31"}
{"parentUuid":"a509cd61-27ad-433b-a0c9-58d120f01593","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013eWNS9aotMzjLnp1RWyrDk","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/app/Services/InventoryService.php"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"bf58f5e3-47d5-43a8-8574-ef94e887efcf","timestamp":"2026-02-13T15:24:04.307Z"}
{"parentUuid":"bf58f5e3-47d5-43a8-8574-ef94e887efcf","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_013eWNS9aotMzjLnp1RWyrDk","toolUseID":"toolu_013eWNS9aotMzjLnp1RWyrDk","timestamp":"2026-02-13T15:24:04.331Z","uuid":"8dcec609-ea91-4e39-a8e2-d837c9dd873f"}
{"parentUuid":"bf58f5e3-47d5-43a8-8574-ef94e887efcf","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013eWNS9aotMzjLnp1RWyrDk","type":"tool_result","content":"     1→<?php\n     2→\n     3→namespace App\\Services;\n     4→\n     5→use App\\Enums\\InventoryPolicy;\n     6→use App\\Exceptions\\InsufficientInventoryException;\n     7→use App\\Models\\InventoryItem;\n     8→use Illuminate\\Support\\Facades\\DB;\n     9→\n    10→class InventoryService\n    11→{\n    12→    /**\n    13→     * Check if sufficient available inventory exists for the given quantity.\n    14→     */\n    15→    public function checkAvailability(InventoryItem $item, int $quantity): bool\n    16→    {\n    17→        if ($item->policy === InventoryPolicy::Continue) {\n    18→            return true;\n    19→        }\n    20→\n    21→        return $item->availableQuantity() >= $quantity;\n    22→    }\n    23→\n    24→    /**\n    25→     * Reserve inventory for a checkout or pending order.\n    26→     * Increments quantity_reserved without changing quantity_on_hand.\n    27→     *\n    28→     * @throws InsufficientInventoryException\n    29→     */\n    30→    public function reserve(InventoryItem $item, int $quantity): void\n    31→    {\n    32→        DB::transaction(function () use ($item, $quantity) {\n    33→            $item->refresh();\n    34→\n    35→            if ($item->policy === InventoryPolicy::Deny && $item->availableQuantity() < $quantity) {\n    36→                throw new InsufficientInventoryException(\n    37→                    $item->variant_id,\n    38→                    $quantity,\n    39→                    $item->availableQuantity(),\n    40→                );\n    41→            }\n    42→\n    43→            $item->increment('quantity_reserved', $quantity);\n    44→        });\n    45→    }\n    46→\n    47→    /**\n    48→     * Release previously reserved inventory (e.g., checkout expired or abandoned).\n    49→     * Decrements quantity_reserved without changing quantity_on_hand.\n    50→     */\n    51→    public function release(InventoryItem $item, int $quantity): void\n    52→    {\n    53→        DB::transaction(function () use ($item, $quantity) {\n    54→            $item->refresh();\n    55→            $item->decrement('quantity_reserved', $quantity);\n    56→        });\n    57→    }\n    58→\n    59→    /**\n    60→     * Commit inventory when payment is confirmed.\n    61→     * Decrements both quantity_on_hand and quantity_reserved.\n    62→     */\n    63→    public function commit(InventoryItem $item, int $quantity): void\n    64→    {\n    65→        DB::transaction(function () use ($item, $quantity) {\n    66→            $item->refresh();\n    67→            $item->decrement('quantity_on_hand', $quantity);\n    68→            $item->decrement('quantity_reserved', $quantity);\n    69→        });\n    70→    }\n    71→\n    72→    /**\n    73→     * Restock inventory (e.g., after a refund with restock flag).\n    74→     * Increments quantity_on_hand.\n    75→     */\n    76→    public function restock(InventoryItem $item, int $quantity): void\n    77→    {\n    78→        DB::transaction(function () use ($item, $quantity) {\n    79→            $item->refresh();\n    80→            $item->increment('quantity_on_hand', $quantity);\n    81→        });\n    82→    }\n    83→}\n    84→"}]},"uuid":"19af28ba-f5eb-4962-a67a-dbf979a60feb","timestamp":"2026-02-13T15:24:04.330Z","sourceToolAssistantUUID":"bf58f5e3-47d5-43a8-8574-ef94e887efcf"}
{"parentUuid":"19af28ba-f5eb-4962-a67a-dbf979a60feb","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","message":{"model":"claude-opus-4-6","id":"msg_018T1J19FESSF7veViPpyXpY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UAMFQ4ZTtENxJMMysEDs8S","name":"Read","input":{"file_path":"/Users/fabianwesner/Herd/shop/app/Services/CheckoutService.php"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":6683,"cache_read_input_tokens":74882,"cache_creation":{"ephemeral_5m_input_tokens":6683,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}},"requestId":"req_011CY6JJCiFdTqEvcKkpBAuT","type":"assistant","uuid":"d1b2061a-e8f7-4b7b-804b-0ef433cdebcf","timestamp":"2026-02-13T15:24:04.790Z"}
{"parentUuid":"d1b2061a-e8f7-4b7b-804b-0ef433cdebcf","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"parentToolUseID":"toolu_01UAMFQ4ZTtENxJMMysEDs8S","toolUseID":"toolu_01UAMFQ4ZTtENxJMMysEDs8S","timestamp":"2026-02-13T15:24:04.810Z","uuid":"bd136a9b-990c-462c-97fa-6eab31394404"}
{"parentUuid":"d1b2061a-e8f7-4b7b-804b-0ef433cdebcf","isSidechain":true,"userType":"external","cwd":"/Users/fabianwesner/Herd/shop","sessionId":"f0c99e5d-9537-48ab-9c23-2e8487ca1cb4","version":"2.1.41","gitBranch":"claude-subagents","agentId":"a3952e1","slug":"wise-shimmying-pretzel","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UAMFQ4ZTtENxJMMysEDs8S","type":"tool_result","content":"     1→<?php\n     2→\n     3→namespace App\\Services;\n     4→\n     5→use App\\Enums\\CartStatus;\n     6→use App\\Enums\\CheckoutStatus;\n     7→use App\\Exceptions\\InvalidCheckoutTransitionException;\n     8→use App\\Models\\Cart;\n     9→use App\\Models\\Checkout;\n    10→use App\\Models\\ShippingRate;\n    11→use Illuminate\\Support\\Facades\\DB;\n    12→use InvalidArgumentException;\n    13→\n    14→class CheckoutService\n    15→{\n    16→    /**\n    17→     * Valid state transitions for the checkout state machine.\n    18→     *\n    19→     * @var array<string, list<string>>\n    20→     */\n    21→    private const TRANSITIONS = [\n    22→        'started' => ['addressed', 'expired'],\n    23→        'addressed' => ['shipping_selected', 'expired'],\n    24→        'shipping_selected' => ['payment_selected', 'expired'],\n    25→        'payment_selected' => ['completed', 'expired'],\n    26→    ];\n    27→\n    28→    public function __construct(\n    29→        private PricingEngine $pricingEngine,\n    30→        private ShippingCalculator $shippingCalculator,\n    31→    ) {}\n    32→\n    33→    /**\n    34→     * Create a new checkout from a cart.\n    35→     */\n    36→    public function createCheckout(Cart $cart): Checkout\n    37→    {\n    38→        if ($cart->lines()->count() === 0) {\n    39→            throw new InvalidArgumentException('Cannot create checkout for empty cart');\n    40→        }\n    41→\n    42→        return Checkout::withoutGlobalScopes()->create([\n    43→            'store_id' => $cart->store_id,\n    44→            'cart_id' => $cart->id,\n    45→            'customer_id' => $cart->customer_id,\n    46→            'status' => CheckoutStatus::Started,\n    47→        ]);\n    48→    }\n    49→\n    50→    /**\n    51→     * Set the address on a checkout.\n    52→     *\n    53→     * @param  array{email: string, shipping_address: array<string, mixed>, billing_address?: array<string, mixed>}  $data\n    54→     */\n    55→    public function setAddress(Checkout $checkout, array $data): Checkout\n    56→    {\n    57→        $this->validateTransition($checkout, CheckoutStatus::Addressed);\n    58→\n    59→        return DB::transaction(function () use ($checkout, $data) {\n    60→            $shippingAddress = $data['shipping_address'];\n    61→            $billingAddress = $data['billing_address'] ?? $shippingAddress;\n    62→\n    63→            $checkout->update([\n    64→                'email' => $data['email'],\n    65→                'shipping_address_json' => $shippingAddress,\n    66→                'billing_address_json' => $billingAddress,\n    67→                'status' => CheckoutStatus::Addressed,\n    68→            ]);\n    69→\n    70→            $this->recalculate($checkout);\n    71→\n    72→            return $checkout->refresh();\n    73→        });\n    74→    }\n    75→\n    76→    /**\n    77→     * Select a shipping method on a checkout.\n    78→     */\n    79→    public function setShippingMethod(Checkout $checkout, int $shippingRateId): Checkout\n    80→    {\n    81→        $this->validateTransition($checkout, CheckoutStatus::ShippingSelected);\n    82→\n    83→        return DB::transaction(function () use ($checkout, $shippingRateId) {\n    84→            $rate = ShippingRate::findOrFail($shippingRateId);\n    85→\n    86→            $checkout->update([\n    87→                'shipping_method_id' => $rate->id,\n    88→                'status' => CheckoutStatus::ShippingSelected,\n    89→            ]);\n    90→\n    91→            $this->recalculate($checkout);\n    92→\n    93→            return $checkout->refresh();\n    94→        });\n    95→    }\n    96→\n    97→    /**\n    98→     * Skip shipping selection when no items require shipping.\n    99→     */\n   100→    public function skipShipping(Checkout $checkout): Checkout\n   101→    {\n   102→        $this->validateTransition($checkout, CheckoutStatus::ShippingSelected);\n   103→\n   104→        return DB::transaction(function () use ($checkout) {\n   105→            $checkout->update([\n   106→                'shipping_method_id' => null,\n   107→                'status' => CheckoutStatus::ShippingSelected,\n   108→            ]);\n   109→\n   110→            $this->recalculate($checkout);\n   111→\n   112→            return $checkout->refresh();\n   113→        });\n   114→    }\n   115→\n   116→    /**\n   117→     * Select a payment method on a checkout.\n   118→     */\n   119→    public function selectPaymentMethod(Checkout $checkout, string $paymentMethod): Checkout\n   120→    {\n   121→        $this->validateTransition($checkout, CheckoutStatus::PaymentSelected);\n   122→\n   123→        $validMethods = ['credit_card', 'paypal', 'bank_transfer'];\n   124→\n   125→        if (! in_array($paymentMethod, $validMethods)) {\n   126→            throw new InvalidArgumentException(\"Invalid payment method: {$paymentMethod}\");\n   127→        }\n   128→\n   129→        return DB::transaction(function () use ($checkout, $paymentMethod) {\n   130→            $checkout->update([\n   131→                'payment_method' => $paymentMethod,\n   132→                'status' => CheckoutStatus::PaymentSelected,\n   133→                'expires_at' => now()->addHours(24),\n   134→            ]);\n   135→\n   136→            $this->recalculate($checkout);\n   137→\n   138→            return $checkout->refresh();\n   139→        });\n   140→    }\n   141→\n   142→    /**\n   143→     * Apply a discount code to a checkout.\n   144→     */\n   145→    public function applyDiscountCode(Checkout $checkout, string $code): Checkout\n   146→    {\n   147→        return DB::transaction(function () use ($checkout, $code) {\n   148→            $checkout->update(['discount_code' => $code]);\n   149→            $this->recalculate($checkout);\n   150→\n   151→            return $checkout->refresh();\n   152→        });\n   153→    }\n   154→\n   155→    /**\n   156→     * Remove discount from a checkout.\n   157→     */\n   158→    public function removeDiscount(Checkout $checkout): Checkout\n   159→    {\n   160→        return DB::transaction(function () use ($checkout) {\n   161→            $checkout->update(['discount_code' => null]);\n   162→            $this->recalculate($checkout);\n   163→\n   164→            return $checkout->refresh();\n   165→        });\n   166→    }\n   167→\n   168→    /**\n   169→     * Transition the checkout to completed state.\n   170→     */\n   171→    public function completeCheckout(Checkout $checkout): Checkout\n   172→    {\n   173→        $this->validateTransition($checkout, CheckoutStatus::Completed);\n   174→\n   175→        return DB::transaction(function () use ($checkout) {\n   176→            $cart = Cart::withoutGlobalScopes()->findOrFail($checkout->cart_id);\n   177→            $cart->update(['status' => CartStatus::Converted]);\n   178→\n   179→            $checkout->update([\n   180→                'status' => CheckoutStatus::Completed,\n   181→            ]);\n   182→\n   183→            return $checkout->refresh();\n   184→        });\n   185→    }\n   186→\n   187→    /**\n   188→     * Expire a checkout.\n   189→     */\n   190→    public function expireCheckout(Checkout $checkout): Checkout\n   191→    {\n   192→        if ($checkout->status === CheckoutStatus::Completed || $checkout->status === CheckoutStatus::Expired) {\n   193→            throw new InvalidCheckoutTransitionException($checkout->status, CheckoutStatus::Expired);\n   194→        }\n   195→\n   196→        $checkout->update([\n   197→            'status' => CheckoutStatus::Expired,\n   198→        ]);\n   199→\n   200→        return $checkout->refresh();\n   201→    }\n   202→\n   203→    /**\n   204→     * Validate that a state transition is allowed.\n   205→     *\n   206→     * @throws InvalidCheckoutTransitionException\n   207→     */\n   208→    private function validateTransition(Checkout $checkout, CheckoutStatus $target): void\n   209→    {\n   210→        $current = $checkout->status->value;\n   211→        $allowed = self::TRANSITIONS[$current] ?? [];\n   212→\n   213→        if (! in_array($target->value, $allowed)) {\n   214→            throw new InvalidCheckoutTransitionException($checkout->status, $target);\n   215→        }\n   216→    }\n   217→\n   218→    /**\n   219→     * Recalculate pricing and store snapshot.\n   220→     */\n   221→    private function recalculate(Checkout $checkout): void\n   222→    {\n   223→        $result = $this->pricingEngine->calculate($checkout);\n   224→\n   225→        $checkout->update([\n   226→            'totals_json' => $result->toArray(),\n   227→        ]);\n   228→    }\n   229→}\n   230→"}]},"uuid":"eee5ac32-5fd6-4776-9f94-dbf25f7b1669","timestamp":"2026-02-13T15:24:04.809Z","sourceToolAssistantUUID":"d1b2061a-e8f7-4b7b-804b-0ef433cdebcf"}

prompt:
You are implementing Phase 5 (Payments, Orders, Fulfillment) of an e-commerce shop in Laravel 12 with Livewire v4, Flux UI Free v2, SQLite, and Pest v4.

## Context
- Phases 1-4 complete. Working directory: /Users/fabianwesner/Herd/shop
- 208 existing tests pass. DO NOT modify existing tests.
- Read specs before implementing: `specs/01-DATABASE-SCHEMA.md`, `specs/05-BUSINESS-LOGIC.md`, `specs/09-IMPLEMENTATION-ROADMAP.md`
- Read existing models/services/factories to understand patterns before coding.

## What to implement

### Step 5.1: Migrations (numbered 000400-000470)
Create tables per specs/01-DATABASE-SCHEMA.md:
- `orders` - store_id, customer_id (nullable), order_number (unique per store), payment_method, status, financial_status, fulfillment_status, currency, amounts (subtotal, discount, shipping, tax, total), email, addresses JSON, placed_at
- `order_lines` - order_id, product_id (nullable SET NULL), variant_id (nullable SET NULL), title_snapshot, sku_snapshot, quantity, unit_price_amount, total_amount, tax_lines_json, discount_allocations_json
- `payments` - order_id, provider ('mock'), method, provider_payment_id, status, amount, currency, raw_json_encrypted
- `refunds` - order_id, payment_id, amount, reason, status, provider_refund_id
- `fulfillments` - order_id, status, tracking_company, tracking_number, tracking_url, shipped_at
- `fulfillment_lines` - fulfillment_id, order_line_id, quantity (unique on fulfillment_id + order_line_id)

### Step 5.2: Models
Create with BelongsToStore where applicable:
- Order (belongsTo Store, Customer; hasMany OrderLine, Payment, Refund, Fulfillment)
- OrderLine (belongsTo Order, Product nullable, ProductVariant nullable)
- Payment (belongsTo Order)
- Refund (belongsTo Order, Payment)
- Fulfillment (belongsTo Order; hasMany FulfillmentLine)
- FulfillmentLine (belongsTo Fulfillment, OrderLine)

Enums: OrderStatus, FinancialStatus, FulfillmentStatus, PaymentMethod, PaymentStatus, RefundStatus, FulfillmentShipmentStatus

### Step 5.3: Payment Service (Mock PSP)
- Interface: `App\Contracts\PaymentProvider` with charge() and refund()
- Implementation: `App\Services\Payments\MockPaymentProvider`
- Magic card numbers: 4242424242424242 (success), 4000000000000002 (decline), 4000000000009995 (insufficient funds)
- Credit card/PayPal: instant capture (status=captured)
- Bank transfer: deferred capture (status=pending)
- Generates mock reference IDs (mock_ + random)
- Register in AppServiceProvider

### Step 5.3b: Admin Payment Confirmation
- Job: CancelUnpaidBankTransferOrders (daily via routes/console.php)
- Admin "Confirm Payment" action in OrderService

### Step 5.4: Order Service (`App\Services\OrderService`)
- createFromCheckout(Checkout): atomic - create order + lines with snapshots, commit inventory, mark cart converted
- generateOrderNumber(Store): sequential per store (#1001, #1002, etc.)
- cancel(Order, reason): only if not fulfilled, releases inventory

### Step 5.5: Refund Service (`App\Services\RefundService`)
- create(Order, Payment, amount, reason, restock): validates amount, calls provider refund, updates financial_status
- Partial refunds -> partially_refunded, full refunds -> refunded
- Restock flag restocks inventory

### Step 5.6: Fulfillment Service (`App\Services\FulfillmentService`)
- create(Order, lines, tracking): creates fulfillment with lines/quantities. GUARD: throws FulfillmentGuardException if financial_status not paid/partially_refunded
- markAsShipped(Fulfillment, tracking): sets tracking, shipped status
- markAsDelivered(Fulfillment): sets delivered status
- Updates order.fulfillment_status (partial/fulfilled)

### Events
Create: OrderCreated, OrderPaid, OrderFulfilled, OrderCancelled, OrderRefunded

### Tests (target 50+ new tests)
- MockPaymentProviderTest: magic cards, PayPal, bank transfer
- OrderServiceTest: create from checkout, generate numbers, cancel
- RefundServiceTest: partial/full refund, restock, validation
- FulfillmentServiceTest: create, ship, deliver, guard check
- PaymentServiceTest: charge flow, bank transfer confirmation

## Rules
- All monetary amounts as INTEGER in minor units
- Use BelongsToStore on store-scoped models (Order)
- Run `vendor/bin/pint --dirty --format agent` before finishing
- Run `php artisan test --compact` to verify ALL tests pass
- DO NOT modify existing test files. DO NOT create git commits.